Skip to content

Commit 687ebb6

Browse files
committed
cache: put WASM compilation behind the action cache
Compiling a module to machine code is cacheable work like any other, so model it as a CompileModule action keyed by the module checksum, wazero version, GOOS, and GOARCH instead of handing wazero a private directory outside the cache's discipline. The action cache gains tree-shaped outputs for tools that read and write output directories: PutTree stores every file under a directory as a named output blob in the CAS, and GetTree materializes them back. Compiled machine code is materialized into exec/<action-hash>, which wazero's compilation cache reads on start; like the rest of the cache, exec trees are safe to delete because the authoritative bytes live in the CAS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPgq3rwip76D554BktbqR4
1 parent 5bdddfe commit 687ebb6

5 files changed

Lines changed: 196 additions & 20 deletions

File tree

‎docs/reference/environment-variables.md‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,16 @@ Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-
2626
The cache is designed after Bazel's local disk cache and has three parts:
2727

2828
- `cas/` — a content-addressable store holding blobs (query analysis
29-
results, WASM plugin binaries) keyed by the SHA-256 hash of their
30-
contents. A remotely fetched plugin's address is exactly the checksum
31-
declared in the configuration file, so it is loaded directly by that
32-
address.
29+
results, WASM plugin binaries, compiled WASM machine code) keyed by the
30+
SHA-256 hash of their contents. A remotely fetched plugin's address is
31+
exactly the checksum declared in the configuration file, so it is loaded
32+
directly by that address.
3333
- `ac/` — an action cache mapping the digest of a unit of cacheable work and
34-
its inputs (for example, analyzing a query against a schema) to the CAS
35-
digests of its outputs.
36-
- `wazero/` — compiled WASM machine code, managed by the
37-
[wazero](https://wazero.io) runtime in its own format.
34+
its inputs (analyzing a query against a schema, compiling a WASM module to
35+
machine code) to the CAS digests of its outputs.
36+
- `exec/` — per-action directories where cached output trees are
37+
materialized for tools that read them from disk, such as the
38+
[wazero](https://wazero.io) runtime's compilation cache.
3839

3940
The entire directory is safe to delete at any time; sqlc will rebuild it as
4041
needed.

‎internal/cache/action.go‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cache
33
import (
44
"encoding/json"
55
"fmt"
6+
"io/fs"
67
"os"
78
"path/filepath"
89
)
@@ -63,6 +64,81 @@ func (a *ActionCache) Get(action Digest) (*ActionResult, error) {
6364
return &result, nil
6465
}
6566

67+
// PutTree stores every file under dir in the CAS and records them as the
68+
// action's outputs, named by their paths relative to dir. Use this for
69+
// actions whose tool writes an output directory, like WASM compilation.
70+
func (a *ActionCache) PutTree(action Digest, dir string) error {
71+
outputs := map[string]Digest{}
72+
err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
73+
if err != nil || entry.IsDir() {
74+
return err
75+
}
76+
rel, err := filepath.Rel(dir, path)
77+
if err != nil {
78+
return err
79+
}
80+
data, err := os.ReadFile(path)
81+
if err != nil {
82+
return err
83+
}
84+
d, err := a.cas.Put(data)
85+
if err != nil {
86+
return err
87+
}
88+
outputs[filepath.ToSlash(rel)] = d
89+
return nil
90+
})
91+
if err != nil {
92+
return fmt.Errorf("cache: %w", err)
93+
}
94+
if len(outputs) == 0 {
95+
return fmt.Errorf("cache: no outputs found under %s", dir)
96+
}
97+
return a.Put(action, &ActionResult{Outputs: outputs})
98+
}
99+
100+
// GetTree materializes a cached action's outputs as files under dir, or
101+
// returns ErrNotFound on a miss. Files already present with the right size
102+
// are left in place; missing ones are staged and renamed so concurrent
103+
// processes never observe partial files.
104+
func (a *ActionCache) GetTree(action Digest, dir string) error {
105+
result, err := a.Get(action)
106+
if err != nil {
107+
return err
108+
}
109+
for rel, d := range result.Outputs {
110+
path := filepath.Join(dir, filepath.FromSlash(rel))
111+
if fi, err := os.Stat(path); err == nil && fi.Size() == d.SizeBytes {
112+
continue
113+
}
114+
data, err := a.cas.Get(d)
115+
if err != nil {
116+
return err
117+
}
118+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
119+
return fmt.Errorf("cache: %w", err)
120+
}
121+
f, err := os.CreateTemp(a.cas.tmp, d.Hash[:8]+"-*")
122+
if err != nil {
123+
return fmt.Errorf("cache: %w", err)
124+
}
125+
if _, err := f.Write(data); err != nil {
126+
f.Close()
127+
os.Remove(f.Name())
128+
return fmt.Errorf("cache: %w", err)
129+
}
130+
if err := f.Close(); err != nil {
131+
os.Remove(f.Name())
132+
return fmt.Errorf("cache: %w", err)
133+
}
134+
if err := os.Rename(f.Name(), path); err != nil {
135+
os.Remove(f.Name())
136+
return fmt.Errorf("cache: %w", err)
137+
}
138+
}
139+
return nil
140+
}
141+
66142
// Put records the result of an action. All outputs must already be in the
67143
// CAS; writes are staged and renamed so concurrent processes never observe a
68144
// partial entry.

‎internal/cache/cache.go‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626

2727
// Cache bundles the CAS and the action cache that shares it.
2828
type Cache struct {
29+
root string
2930
CAS *CAS
3031
Actions *ActionCache
3132
}
@@ -61,20 +62,19 @@ func OpenAt(root string) (*Cache, error) {
6162
return nil, err
6263
}
6364
return &Cache{
65+
root: root,
6466
CAS: cas,
6567
Actions: newActionCache(root, cas),
6668
}, nil
6769
}
6870

69-
// WazeroDir returns the directory for wazero's compilation cache. Compiled
70-
// module machine code is managed by wazero in its own format, so it lives
71-
// beside the CAS rather than inside it.
72-
func WazeroDir() (string, error) {
73-
root, err := Dir()
74-
if err != nil {
75-
return "", err
76-
}
77-
dir := filepath.Join(root, "wazero")
71+
// ExecDir returns a stable directory for materializing the output tree of
72+
// the given action, for tools that need their outputs on disk (like wazero's
73+
// compilation cache). It lives at exec/<action-hash> under the cache root
74+
// and, like everything else in the cache, is safe to delete at any time: the
75+
// authoritative copy of its contents is the CAS.
76+
func (c *Cache) ExecDir(action Digest) (string, error) {
77+
dir := filepath.Join(c.root, "exec", action.Hash)
7878
if err := os.MkdirAll(dir, 0755); err != nil {
7979
return "", fmt.Errorf("failed to create %s directory: %w", dir, err)
8080
}

‎internal/cache/cache_test.go‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/hex"
66
"errors"
77
"os"
8+
"path/filepath"
89
"testing"
910
)
1011

@@ -197,6 +198,63 @@ func TestActionCacheMissingOutputIsMiss(t *testing.T) {
197198
}
198199
}
199200

201+
func TestActionCacheTreeRoundTrip(t *testing.T) {
202+
c := testCache(t)
203+
204+
// Simulate a tool writing an output directory, like wazero's
205+
// compilation cache.
206+
src := t.TempDir()
207+
files := map[string]string{
208+
"wazero-v1-amd64-linux/compiled": "machine code",
209+
"manifest": "meta",
210+
}
211+
for rel, contents := range files {
212+
path := filepath.Join(src, filepath.FromSlash(rel))
213+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
214+
t.Fatal(err)
215+
}
216+
if err := os.WriteFile(path, []byte(contents), 0644); err != nil {
217+
t.Fatal(err)
218+
}
219+
}
220+
221+
action := NewAction("CompileModule").AddInput("wasm", []byte("checksum")).Digest()
222+
if err := c.Actions.GetTree(action, t.TempDir()); !errors.Is(err, ErrNotFound) {
223+
t.Errorf("want ErrNotFound before PutTree, got %v", err)
224+
}
225+
if err := c.Actions.PutTree(action, src); err != nil {
226+
t.Fatal(err)
227+
}
228+
229+
// Materialize into a fresh directory and compare contents.
230+
dst := t.TempDir()
231+
if err := c.Actions.GetTree(action, dst); err != nil {
232+
t.Fatal(err)
233+
}
234+
for rel, contents := range files {
235+
got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(rel)))
236+
if err != nil {
237+
t.Fatal(err)
238+
}
239+
if string(got) != contents {
240+
t.Errorf("%s: got %q, want %q", rel, got, contents)
241+
}
242+
}
243+
244+
// Materializing again over the same directory is a no-op.
245+
if err := c.Actions.GetTree(action, dst); err != nil {
246+
t.Fatal(err)
247+
}
248+
}
249+
250+
func TestActionCachePutTreeRejectsEmptyDir(t *testing.T) {
251+
c := testCache(t)
252+
action := NewAction("CompileModule").Digest()
253+
if err := c.Actions.PutTree(action, t.TempDir()); err == nil {
254+
t.Error("PutTree must reject a directory with no files")
255+
}
256+
}
257+
200258
func TestActionCachePutRejectsMissingOutput(t *testing.T) {
201259
c := testCache(t)
202260
action := NewAction("Test").Digest()

‎internal/ext/wasm/wasm.go‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"net/http"
1212
"os"
1313
"runtime"
14+
"runtime/debug"
1415
"strings"
1516

1617
"github.com/tetratelabs/wazero"
@@ -134,11 +135,30 @@ func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, exp
134135
return nil, err
135136
}
136137

137-
wazeroDir, err := cache.WazeroDir()
138+
// Compiling the module to machine code is itself a cacheable action,
139+
// keyed by the module's checksum and everything else that determines the
140+
// generated code. Compiled artifacts are materialized into an exec
141+
// directory for wazero's compilation cache to find; the authoritative
142+
// copies live in the CAS.
143+
compileAction := cache.NewAction("CompileModule").
144+
AddInput("wasm", []byte(expected)).
145+
AddInput("wazero", []byte(wazeroVersion())).
146+
AddInput("goos", []byte(runtime.GOOS)).
147+
AddInput("goarch", []byte(runtime.GOARCH)).
148+
Digest()
149+
150+
execDir, err := store.ExecDir(compileAction)
138151
if err != nil {
139152
return nil, err
140153
}
141-
wazeroCache, err := wazero.NewCompilationCacheWithDir(wazeroDir)
154+
compiled := true
155+
if err := store.Actions.GetTree(compileAction, execDir); errors.Is(err, cache.ErrNotFound) {
156+
compiled = false
157+
} else if err != nil {
158+
return nil, err
159+
}
160+
161+
wazeroCache, err := wazero.NewCompilationCacheWithDir(execDir)
142162
if err != nil {
143163
return nil, fmt.Errorf("wazero.NewCompilationCacheWithDir: %w", err)
144164
}
@@ -151,15 +171,36 @@ func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, exp
151171
}
152172

153173
// Compile the Wasm binary once so that we can skip the entire compilation
154-
// time during instantiation.
174+
// time during instantiation. On an action cache hit this loads the
175+
// materialized machine code instead of compiling.
155176
code, err := rt.CompileModule(ctx, wmod)
156177
if err != nil {
157178
return nil, fmt.Errorf("compile module: %w", err)
158179
}
159180

181+
if !compiled {
182+
if err := store.Actions.PutTree(compileAction, execDir); err != nil {
183+
slog.Warn("caching compiled module failed", "err", err)
184+
}
185+
}
186+
160187
return &runtimeAndCode{rt: rt, code: code}, nil
161188
}
162189

190+
// wazeroVersion returns the version of the wazero dependency, an input to
191+
// the CompileModule action: its generated machine code changes between
192+
// wazero releases.
193+
func wazeroVersion() string {
194+
if bi, ok := debug.ReadBuildInfo(); ok {
195+
for _, dep := range bi.Deps {
196+
if dep.Path == "github.com/tetratelabs/wazero" {
197+
return dep.Version
198+
}
199+
}
200+
}
201+
return info.Version
202+
}
203+
163204
// removePGCatalog removes the pg_catalog schema from the request. There is a
164205
// mysterious (reason unknown) bug with wasm plugins when a large amount of
165206
// tables (like there are in the catalog) are sent.

0 commit comments

Comments
 (0)