Visitar URL original
core: export cache entries through a general hidden engine.localCache._export by sipsma · Pull Request #15127 · dagger/dagger · GitHub
Skip to content

core: export cache entries through a general hidden engine.localCache._export - #15127

Open
sipsma wants to merge 8 commits into
mainfrom
sipsma/general-cache-export
Open

sipsma wants to merge 8 commits into
mainfrom
sipsma/general-cache-export

Conversation

@sipsma

@sipsma sipsma commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

The engine image's pre-warmed cache was made by a hidden field that only the image's build called, Query._engineImageBundle. It picked each result's parts from a table of types, and reached into a Git module source for its checkout. This replaces it with a general export, engine.localCache._export(results, forcedCompression), which the image's build now calls like any other caller. Nothing changes for users.

What the export does. It writes any results' cache entries to a directory as bundle.json plus their blobs. That is the format the engine merges from the image at start, which is unchanged.

  • Each result carries its own parts, read from its record, with no list of types. A container brings its image config, filesystem and exec metadata, because those are its parts.
  • A result with no parts of its own carries the parts of the results it holds instead. A Git module source has none, so it carries its checkout's.
  • What made a result, such as the execs of a build, travels as records only. A lookup of the result reads its own data, not theirs.
  • It evaluates each result and every part it declares before exporting, and fails if a part is still incomplete afterwards.

Removed:

  • Query._engineImageBundle and its type table.
  • dagql.Cache.ExportImageBundle. It is now dagql.Cache.ExportBundle, which has no image-specific rules.

Unchanged:

  • The engine's merge at start, from the image's fixed directory.
  • The image's entries are never sent to the Cloud.
  • Module._prebuild and Module._runtimeImage are kept. Their descriptions now present them as a general capability: a module's runtime for a given platform, built without running it.

The built-in Go SDK's shared dependency calls, shipped in the image. The Go SDK now builds every module runtime with dagger/go's sharedImports set to what generated code imports (distconsts.GoSDKSharedImports; dagger/go #104). engine-image-entries builds its seeds with the same list. It exports the two shared dependency calls' results through engine.localCache._export. Each is a module object holding package builds, and the export carries its record and the files and directories it holds.

A Go module's first build on a fresh engine used to look up each of ~450 packages one by one. Now it finds the standard library's call and the generated code's call whole, and looks up only its own other packages. A module that requires newer versions of the base modules still finds the standard library's call.

Measured on one 16-CPU amd64 machine, against main with dagger/go #104's other commits:

  • 0.3–0.5 s faster idle, and 0.7 s faster with five loads at once;
  • about 950 fewer API requests per first build;
  • half the CLI's CPU;
  • the image is 12 MB larger compressed (639 → 651 MB).

Tests: TestGo/TestRuntimeFindsSharedDependenciesInImage runs in test-module-runtimes with the image's entries, and cmd/engine-image-entries checks that the list covers what codegendeps pins. The built-in Go SDK's dagger/go pin and the workspace's go-sdk pin move together. The workspace's go-sdk pin is go-sdk v1.6.0 plus one commit that points its generated entrypoints at the same dagger/go as the built-in Go SDK, as the image's build requires. Both dagger/go pins follow dagger/go #104, which is still a draft, and move again to its final head before this merges.

Version gate: _export is new after v1.0.0-beta.27, so it is served only to views from v1.0.0-beta.28 on, as other APIs are gated to the version that first serves them.

Known limit: files a container mounts with withMountedFiles are results it holds, not parts of its own, so they travel as records only. The previous field didn't carry them either, and the image ships no such container.

Measured on one m8gd.4xlarge, comparing main 8376266 with this change. Each arm was built as a full engine image with its entries, and each run was a fresh engine running dagger -W github.com/dagger/dagger@83762668d8 check -l, in 4 interleaved pairs:

main this change
Fresh listing, median 6.2 s 6.1 s
Shipped bundle 767.1 MB 767.7 MB
Engine image 609.1 MB 609.3 MB

Tests, in RemoteCacheTransferSuite, which CI runs with the image's entries:

  • Exporting an exec container carries all three of its parts.
  • Exporting a directory an exec wrote carries only the directory's data, with the exec as a record.
  • Exporting a Git module source on its own carries its checkout's data.

Every dev engine build also exercises the export, because it builds the image's entries with it.

@sipsma
sipsma force-pushed the sipsma/general-cache-export branch 3 times, most recently from 45c28a1 to 28abf8f Compare November 11, 2026 00:27
@sipsma
sipsma requested a review from a team as a code owner November 11, 2026 10:02
The engine image's entries were exported by Query._engineImageBundle, a
field only the image's build called. It picked each result's parts from a
table of types, and reached into a Git module source for its checkout.

engine.localCache._export replaces it. It exports any results' cache
entries as a bundle directory, with the parts of each result and of every
result its value holds, read from their records rather than from a table of
types. It evaluates each of them, and every part they declare, first. A Git
module source carries its checkout because the source's value holds it; a
container carries its image config, filesystem and exec metadata because
they are its parts. A caller may force the layers' compression, as the
image's build does with uncompressed layers.

cmd/engine-image-entries calls it; the engine still merges the bundle at
the image's fixed path at start.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
Module._prebuild and Module._runtimeImage build a module's runtime pieces as
an engine of a given platform builds them for its calls, without running the
module there. Say that, rather than naming the engine image as their user.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
…lure

A result an export reaches through what a value holds was held undecoded,
and an undecoded value evaluates nothing, so a part it declared could stay
pending and be left out of the bundle. The export now decodes each one, and
fails if a declared part is still not complete after evaluating it.

A failed export released nothing it had allocated for its output; it now
releases the output's ref.

The test of a module source's export exports the source alone, so its
checkout can only arrive through what the source holds.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
A cache export carried the parts of every result a value held, followed
down through all of them. For a directory or a container those are what
made it, such as the execs a build ran, whose data a lookup of the result
never reads: the engine image's bundle grew from 767 MB to 1.8 GB.

The export now carries each result's own parts, and looks into what a
result holds only where the result has no parts of its own, as a module
source has none and carries its checkout's. What made a result with parts
travels as records only.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
…as records

An exported directory that an exec wrote carries its own snapshot's data,
and the exec that made it only as a record, which a lookup of the
directory never reads.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
The export is new after v1.0.0-beta.27, so a caller that declares an earlier
view gets the API that version's engine served, without it.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
Its description still said it carries the data of every result a value
holds. It carries a result's own data, and looks into what a result holds
only where the result has none of its own.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
…whole

A Go module's first runtime build on a fresh engine found each of its ~450
dependency packages in the image's entries, but one at a time: the image
shipped each package's build and not the dependency call that builds them,
which a module's own imports make unique to it.

The built-in Go SDK now builds every runtime with dagger/go's shared
imports set to what generated code imports (GoSDKSharedImports). dagger/go
then compiles the packages those reach in dependency calls of their own,
the standard library's and the rest's, which are the same calls in every
module that keeps the SDK's versions of those packages' modules, whatever
else it imports. The image's build makes those calls for its seed and
ships their results with the package builds, so a fresh engine finds each
call whole, and looks up only the module's own other packages.

It moves the built-in Go SDK's dagger/go pin to the commit that adds
sharedImports and sharedDependencies (dagger/go #104), and the workspace's
go-sdk pin to a go-sdk commit whose daggerGo names the same dagger/go, which
the image's build requires of the two.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
@sipsma
sipsma force-pushed the sipsma/general-cache-export branch from 1caa7ee to 2aa97de Compare November 11, 2026 17:56
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.

1 participant