From 3c674f17ea2bd903f664818f3ecd66d84a0659ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 05:45:06 +0000 Subject: [PATCH 1/9] cache: redesign as a Bazel-style CAS and action cache Replace the ad-hoc on-disk caches with a design modeled on Bazel's local disk cache: - A content-addressable store (cas/) holding blobs keyed by the BLAKE3 hash of their contents, written atomically and verified on read, with corrupt entries evicted and repaired. - An action cache (ac/) mapping the digest of a unit of cacheable work and its inputs to the CAS digests of its outputs. Entries whose outputs are missing from the CAS are treated as misses. The query analysis cache becomes a QueryAnalysis action keyed by the sqlc version, config, schema, and query, with the marshaled analysis stored in the CAS. The WASM plugin cache becomes a FetchPlugin action keyed by the URL and expected sha256, with the plugin binary stored in the CAS; the declared sha256 is still re-verified on every cache hit. wazero's compiled-code cache keeps its own directory (wazero/) since its format is managed by the runtime. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MPgq3rwip76D554BktbqR4 --- docs/reference/environment-variables.md | 17 ++- go.mod | 2 + go.sum | 4 + internal/analyzer/analyzer.go | 55 ++++---- internal/cache/action.go | 99 +++++++++++++ internal/cache/cache.go | 62 +++++++-- internal/cache/cache_test.go | 178 ++++++++++++++++++++++++ internal/cache/cas.go | 98 +++++++++++++ internal/cache/digest.go | 85 +++++++++++ internal/ext/wasm/wasm.go | 76 ++++++---- 10 files changed, 609 insertions(+), 67 deletions(-) create mode 100644 internal/cache/action.go create mode 100644 internal/cache/cache_test.go create mode 100644 internal/cache/cas.go create mode 100644 internal/cache/digest.go diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 837dd13980..bb54a4439f 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -20,10 +20,23 @@ they are introduced. ## SQLCCACHE The `SQLCCACHE` environment variable dictates where `sqlc` will store cached -WASM-based plugins and modules. By default `sqlc` follows the [XDG Base -Directory +data. By default `sqlc` follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html). +The cache is designed after Bazel's local disk cache and has three parts: + +- `cas/` — a content-addressable store holding blobs (WASM plugin binaries, + query analysis results) keyed by the BLAKE3 hash of their contents. +- `ac/` — an action cache mapping the digest of a unit of cacheable work and + its inputs (for example, fetching a plugin from a URL with an expected + checksum, or analyzing a query against a schema) to the CAS digests of its + outputs. +- `wazero/` — compiled WASM machine code, managed by the + [wazero](https://wazero.io) runtime in its own format. + +The entire directory is safe to delete at any time; sqlc will rebuild it as +needed. + ## SQLCDEBUG The `SQLCDEBUG` variable controls debugging variables within the runtime. It is diff --git a/go.mod b/go.mod index c7af41a33f..827f82dca7 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( google.golang.org/grpc v1.83.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 + lukechampine.com/blake3 v1.4.1 modernc.org/sqlite v1.55.0 ) @@ -43,6 +44,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/kr/text v0.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-sqlite3-wasm/v3 v3.2.35303 // indirect diff --git a/go.sum b/go.sum index 5c65f8e522..0bc8834761 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -149,6 +151,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= +lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 674f283db9..0db94f5ed0 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -3,11 +3,8 @@ package analyzer import ( "context" "encoding/json" - "fmt" - "hash/fnv" + "errors" "log/slog" - "os" - "path/filepath" "google.golang.org/protobuf/proto" @@ -47,6 +44,9 @@ func (c *CachedAnalyzer) Analyze(ctx context.Context, n ast.Node, q string, sche return result, err } +// The name of the sole output blob a QueryAnalysis action produces. +const analysisOutput = "analysis.pb" + func (c *CachedAnalyzer) analyze(ctx context.Context, n ast.Node, q string, schema []string, np *named.ParamSet) (*analysis.Analysis, bool, error) { // Only cache queries for managed databases. We can't be certain the // database is in an unchanged state otherwise @@ -54,7 +54,7 @@ func (c *CachedAnalyzer) analyze(ctx context.Context, n ast.Node, q string, sche return nil, true, nil } - dir, err := cache.AnalysisDir() + store, err := cache.Open() if err != nil { return nil, true, err } @@ -66,27 +66,27 @@ func (c *CachedAnalyzer) analyze(ctx context.Context, n ast.Node, q string, sche } } - // Calculate cache key - h := fnv.New64() - h.Write([]byte(info.Version)) - h.Write(c.configBytes) + // Analyzing a query is an action whose inputs are the sqlc version, the + // configuration, the schema migrations, and the query itself. + action := cache.NewAction("QueryAnalysis"). + AddInput("version", []byte(info.Version)). + AddInput("config", c.configBytes) for _, m := range schema { - h.Write([]byte(m)) + action.AddInput("schema", []byte(m)) } - h.Write([]byte(q)) - - key := fmt.Sprintf("%x", h.Sum(nil)) - path := filepath.Join(dir, key) - if _, err := os.Stat(path); err == nil { - contents, err := os.ReadFile(path) - if err != nil { - return nil, true, err + actionDigest := action.AddInput("query", []byte(q)).Digest() + + if cached, err := store.Actions.Get(actionDigest); err == nil { + contents, err := store.CAS.Get(cached.Outputs[analysisOutput]) + if err == nil { + var a analysis.Analysis + if err := proto.Unmarshal(contents, &a); err == nil { + return &a, false, nil + } } - var a analysis.Analysis - if err := proto.Unmarshal(contents, &a); err != nil { - return nil, true, err + if !errors.Is(err, cache.ErrNotFound) { + slog.Warn("reading analysis from cache failed", "err", err) } - return &a, false, nil } result, err := c.a.Analyze(ctx, n, q, schema, np) @@ -97,10 +97,17 @@ func (c *CachedAnalyzer) analyze(ctx context.Context, n ast.Node, q string, sche slog.Warn("unable to marshal analysis", "err", err) return result, false, nil } - if err := os.WriteFile(path, contents, 0644); err != nil { - slog.Warn("saving analysis to disk failed", "err", err) + outDigest, err := store.CAS.Put(contents) + if err != nil { + slog.Warn("saving analysis to cache failed", "err", err) return result, false, nil } + err = store.Actions.Put(actionDigest, &cache.ActionResult{ + Outputs: map[string]cache.Digest{analysisOutput: outDigest}, + }) + if err != nil { + slog.Warn("saving analysis action result failed", "err", err) + } } return result, false, err diff --git a/internal/cache/action.go b/internal/cache/action.go new file mode 100644 index 0000000000..67a1a4f17c --- /dev/null +++ b/internal/cache/action.go @@ -0,0 +1,99 @@ +package cache + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// ActionResult records the outputs of a completed action, mirroring Bazel's +// ActionResult message. Outputs are not stored inline: each named output is a +// digest pointing into the CAS. +type ActionResult struct { + // Outputs maps an output name (e.g. "analysis.pb", "plugin.wasm") to the + // CAS digest of its contents. + Outputs map[string]Digest `json:"outputs"` +} + +// ActionCache maps action digests to ActionResults, stored as JSON files at +// ac// under the cache root. Unlike CAS entries, action cache +// entries are not self-validating — the value is not derivable from the key — +// so Get additionally checks that every referenced output still exists in the +// CAS before reporting a hit, exactly like Bazel's disk cache does. +type ActionCache struct { + root string + cas *CAS +} + +func newActionCache(root string, cas *CAS) *ActionCache { + return &ActionCache{root: root, cas: cas} +} + +func (a *ActionCache) path(d Digest) string { + return filepath.Join(a.root, "ac", d.Hash[:2], d.Hash) +} + +// Get returns the cached result for an action, or ErrNotFound on a miss. An +// entry whose outputs are missing or corrupt in the CAS is treated as a miss +// and evicted. +func (a *ActionCache) Get(action Digest) (*ActionResult, error) { + if !action.valid() { + return nil, ErrNotFound + } + path := a.path(action) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("cache: %w", err) + } + var result ActionResult + if err := json.Unmarshal(data, &result); err != nil { + os.Remove(path) + return nil, ErrNotFound + } + for _, d := range result.Outputs { + if !a.cas.Contains(d) { + os.Remove(path) + return nil, ErrNotFound + } + } + return &result, nil +} + +// Put records the result of an action. All outputs must already be in the +// CAS; writes are staged and renamed so concurrent processes never observe a +// partial entry. +func (a *ActionCache) Put(action Digest, result *ActionResult) error { + for name, d := range result.Outputs { + if !a.cas.Contains(d) { + return fmt.Errorf("cache: output %q (%s) missing from CAS", name, d) + } + } + data, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("cache: %w", err) + } + path := a.path(action) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("cache: %w", err) + } + f, err := os.CreateTemp(a.cas.tmp, action.Hash[:8]+"-*") + if err != nil { + return fmt.Errorf("cache: %w", err) + } + defer os.Remove(f.Name()) + if _, err := f.Write(data); err != nil { + f.Close() + return fmt.Errorf("cache: %w", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("cache: %w", err) + } + if err := os.Rename(f.Name(), path); err != nil { + return fmt.Errorf("cache: %w", err) + } + return nil +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index a6978034a7..ca64ee3c8a 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -1,3 +1,18 @@ +// Package cache implements sqlc's on-disk cache, designed after Bazel's +// local disk cache. +// +// It has two halves: +// +// - A content-addressable store (CAS) holding blobs keyed by the BLAKE3 +// hash of their contents, laid out as cas//. +// - An action cache (AC) mapping the digest of an action — a description of +// cacheable work and all of its inputs — to the digests of the outputs +// that work produced, laid out as ac//. +// +// Executing cached work therefore takes two steps, just like Bazel: hash the +// action, look its digest up in the action cache, then fetch the referenced +// output blobs from the CAS. Both the query analysis cache and the WASM +// plugin cache are built on this pair. package cache import ( @@ -6,10 +21,14 @@ import ( "path/filepath" ) -// The cache directory defaults to os.UserCacheDir(). This location can be -// overridden by the SQLCCACHE environment variable. -// -// Currently the cache stores two types of data: plugins and query analysis +// Cache bundles the CAS and the action cache that shares it. +type Cache struct { + CAS *CAS + Actions *ActionCache +} + +// Dir returns the cache root, defaulting to os.UserCacheDir(). The location +// can be overridden with the SQLCCACHE environment variable. func Dir() (string, error) { cache := os.Getenv("SQLCCACHE") if cache != "" { @@ -22,25 +41,38 @@ func Dir() (string, error) { return filepath.Join(cacheHome, "sqlc"), nil } -func PluginsDir() (string, error) { - cacheRoot, err := Dir() +// Open returns the cache rooted at Dir(). +func Open() (*Cache, error) { + root, err := Dir() if err != nil { - return "", err + return nil, err } - dir := filepath.Join(cacheRoot, "plugins") - if err := os.MkdirAll(dir, 0755); err != nil && !os.IsExist(err) { - return "", fmt.Errorf("failed to create %s directory: %w", dir, err) + return OpenAt(root) +} + +// OpenAt returns the cache rooted at the given directory, creating it if +// necessary. +func OpenAt(root string) (*Cache, error) { + cas, err := newCAS(root) + if err != nil { + return nil, err } - return dir, nil + return &Cache{ + CAS: cas, + Actions: newActionCache(root, cas), + }, nil } -func AnalysisDir() (string, error) { - cacheRoot, err := Dir() +// WazeroDir returns the directory for wazero's compilation cache. Compiled +// module machine code is managed by wazero in its own format, so it lives +// beside the CAS rather than inside it. +func WazeroDir() (string, error) { + root, err := Dir() if err != nil { return "", err } - dir := filepath.Join(cacheRoot, "query_analysis") - if err := os.MkdirAll(dir, 0755); err != nil && !os.IsExist(err) { + dir := filepath.Join(root, "wazero") + if err := os.MkdirAll(dir, 0755); err != nil { return "", fmt.Errorf("failed to create %s directory: %w", dir, err) } return dir, nil diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000000..e020b10803 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,178 @@ +package cache + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func testCache(t *testing.T) *Cache { + t.Helper() + c, err := OpenAt(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return c +} + +func TestCASRoundTrip(t *testing.T) { + c := testCache(t) + blob := []byte("SELECT id, name FROM authors") + + d, err := c.CAS.Put(blob) + if err != nil { + t.Fatal(err) + } + if d != DigestOf(blob) { + t.Errorf("digest mismatch: %s != %s", d, DigestOf(blob)) + } + if !c.CAS.Contains(d) { + t.Error("Contains returned false for stored blob") + } + + got, err := c.CAS.Get(d) + if err != nil { + t.Fatal(err) + } + if string(got) != string(blob) { + t.Errorf("got %q, want %q", got, blob) + } + + // Idempotent re-put + if _, err := c.CAS.Put(blob); err != nil { + t.Fatal(err) + } +} + +func TestCASMiss(t *testing.T) { + c := testCache(t) + if _, err := c.CAS.Get(DigestOf([]byte("never stored"))); !errors.Is(err, ErrNotFound) { + t.Errorf("want ErrNotFound, got %v", err) + } + if c.CAS.Contains(Digest{Hash: "zz", SizeBytes: 1}) { + t.Error("Contains returned true for invalid digest") + } +} + +func TestCASCorruptionEvicted(t *testing.T) { + c := testCache(t) + d, err := c.CAS.Put([]byte("original contents")) + if err != nil { + t.Fatal(err) + } + + // Corrupt the blob on disk while keeping its size. + path := filepath.Join(c.CAS.root, "cas", d.Hash[:2], d.Hash) + if err := os.WriteFile(path, []byte("tampered contents"), 0644); err != nil { + t.Fatal(err) + } + + if _, err := c.CAS.Get(d); !errors.Is(err, ErrNotFound) { + t.Errorf("want ErrNotFound for corrupt blob, got %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("corrupt blob was not evicted") + } +} + +func TestCASPutRepairsCorruptEntry(t *testing.T) { + c := testCache(t) + blob := []byte("plugin bytes") + d, err := c.CAS.Put(blob) + if err != nil { + t.Fatal(err) + } + + // Corrupt the entry on disk with different-sized contents, then Put the + // correct blob again: the entry must be repaired, not trusted. + path := filepath.Join(c.CAS.root, "cas", d.Hash[:2], d.Hash) + if err := os.WriteFile(path, append(blob, "tampered"...), 0644); err != nil { + t.Fatal(err) + } + if _, err := c.CAS.Put(blob); err != nil { + t.Fatal(err) + } + got, err := c.CAS.Get(d) + if err != nil { + t.Fatal(err) + } + if string(got) != string(blob) { + t.Errorf("got %q, want %q", got, blob) + } +} + +func TestActionDigestFraming(t *testing.T) { + a := NewAction("Test").AddInput("query", []byte("ab")).AddInput("schema", []byte("c")) + b := NewAction("Test").AddInput("query", []byte("a")).AddInput("schema", []byte("bc")) + if a.Digest() == b.Digest() { + t.Error("shifting bytes between inputs must change the action digest") + } + + x := NewAction("Test").AddInput("query", []byte("q")) + y := NewAction("Test").AddInput("query", []byte("q")) + if x.Digest() != y.Digest() { + t.Error("identical actions must have identical digests") + } +} + +func TestActionCacheRoundTrip(t *testing.T) { + c := testCache(t) + out, err := c.CAS.Put([]byte("analysis result")) + if err != nil { + t.Fatal(err) + } + + action := NewAction("QueryAnalysis").AddInput("query", []byte("SELECT 1")).Digest() + if _, err := c.Actions.Get(action); !errors.Is(err, ErrNotFound) { + t.Errorf("want ErrNotFound before Put, got %v", err) + } + + if err := c.Actions.Put(action, &ActionResult{ + Outputs: map[string]Digest{"analysis.pb": out}, + }); err != nil { + t.Fatal(err) + } + + result, err := c.Actions.Get(action) + if err != nil { + t.Fatal(err) + } + if result.Outputs["analysis.pb"] != out { + t.Errorf("got %s, want %s", result.Outputs["analysis.pb"], out) + } +} + +func TestActionCacheMissingOutputIsMiss(t *testing.T) { + c := testCache(t) + out, err := c.CAS.Put([]byte("ephemeral")) + if err != nil { + t.Fatal(err) + } + action := NewAction("Test").AddInput("in", []byte("x")).Digest() + if err := c.Actions.Put(action, &ActionResult{ + Outputs: map[string]Digest{"out": out}, + }); err != nil { + t.Fatal(err) + } + + // Simulate the blob being garbage collected out from under the entry. + if err := os.Remove(filepath.Join(c.CAS.root, "cas", out.Hash[:2], out.Hash)); err != nil { + t.Fatal(err) + } + + if _, err := c.Actions.Get(action); !errors.Is(err, ErrNotFound) { + t.Errorf("want ErrNotFound when output blob is gone, got %v", err) + } +} + +func TestActionCachePutRejectsMissingOutput(t *testing.T) { + c := testCache(t) + action := NewAction("Test").Digest() + err := c.Actions.Put(action, &ActionResult{ + Outputs: map[string]Digest{"out": DigestOf([]byte("never stored"))}, + }) + if err == nil { + t.Error("Put must reject results whose outputs are not in the CAS") + } +} diff --git a/internal/cache/cas.go b/internal/cache/cas.go new file mode 100644 index 0000000000..9577a68afa --- /dev/null +++ b/internal/cache/cas.go @@ -0,0 +1,98 @@ +package cache + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// ErrNotFound is returned when a blob or action result is not in the cache. +var ErrNotFound = errors.New("cache: not found") + +// CAS is an on-disk content-addressable store keyed by BLAKE3, modeled on +// Bazel's disk cache. Blobs live at cas// under the cache root, +// where is the first two hex characters of the hash. Because a blob's +// name is derived from its contents, entries never change once written: +// writers race benignly and readers can detect corruption by re-hashing. +type CAS struct { + root string + tmp string +} + +func newCAS(root string) (*CAS, error) { + tmp := filepath.Join(root, "tmp") + if err := os.MkdirAll(tmp, 0755); err != nil { + return nil, fmt.Errorf("cache: create %s: %w", tmp, err) + } + return &CAS{root: root, tmp: tmp}, nil +} + +func (c *CAS) path(d Digest) string { + return filepath.Join(c.root, "cas", d.Hash[:2], d.Hash) +} + +// Put stores a blob and returns its digest. Writing is atomic: the blob is +// staged in a temp file and renamed into place, so concurrent sqlc processes +// never observe partial entries. +func (c *CAS) Put(data []byte) (Digest, error) { + d := DigestOf(data) + path := c.path(d) + // Skip the write only when an entry of the right size already exists; a + // wrong-sized entry is corrupt and is atomically replaced by the rename + // below. + if fi, err := os.Stat(path); err == nil && fi.Size() == d.SizeBytes { + return d, nil + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return Digest{}, fmt.Errorf("cache: %w", err) + } + f, err := os.CreateTemp(c.tmp, d.Hash[:8]+"-*") + if err != nil { + return Digest{}, fmt.Errorf("cache: %w", err) + } + defer os.Remove(f.Name()) + if _, err := f.Write(data); err != nil { + f.Close() + return Digest{}, fmt.Errorf("cache: %w", err) + } + if err := f.Close(); err != nil { + return Digest{}, fmt.Errorf("cache: %w", err) + } + if err := os.Rename(f.Name(), path); err != nil { + return Digest{}, fmt.Errorf("cache: %w", err) + } + return d, nil +} + +// Get returns the blob for a digest. Contents are re-hashed before being +// returned; a corrupt entry is evicted and reported as ErrNotFound so callers +// simply re-run the action that produced it. +func (c *CAS) Get(d Digest) ([]byte, error) { + if !d.valid() { + return nil, ErrNotFound + } + path := c.path(d) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("cache: %w", err) + } + if DigestOf(data) != d { + os.Remove(path) + return nil, ErrNotFound + } + return data, nil +} + +// Contains reports whether a blob with the given digest and size is present. +// It does not verify contents; Get performs full verification. +func (c *CAS) Contains(d Digest) bool { + if !d.valid() { + return false + } + fi, err := os.Stat(c.path(d)) + return err == nil && fi.Size() == d.SizeBytes +} diff --git a/internal/cache/digest.go b/internal/cache/digest.go new file mode 100644 index 0000000000..7e5a75a7e8 --- /dev/null +++ b/internal/cache/digest.go @@ -0,0 +1,85 @@ +package cache + +import ( + "encoding/binary" + "encoding/hex" + "fmt" + + "lukechampine.com/blake3" +) + +// Digest identifies a blob by its BLAKE3 hash and size, mirroring the Digest +// message from Bazel's remote execution API. The size is stored alongside the +// hash so that entries can be validated without reading blob contents. +type Digest struct { + // Hash is the lowercase hex-encoded BLAKE3-256 hash of the blob. + Hash string `json:"hash"` + // SizeBytes is the length of the blob in bytes. + SizeBytes int64 `json:"size_bytes"` +} + +func (d Digest) String() string { + return fmt.Sprintf("blake3:%s/%d", d.Hash, d.SizeBytes) +} + +func (d Digest) valid() bool { + if len(d.Hash) != 64 || d.SizeBytes < 0 { + return false + } + _, err := hex.DecodeString(d.Hash) + return err == nil +} + +// DigestOf returns the Digest of a blob. +func DigestOf(data []byte) Digest { + sum := blake3.Sum256(data) + return Digest{ + Hash: hex.EncodeToString(sum[:]), + SizeBytes: int64(len(data)), + } +} + +// An Action describes a unit of cacheable work, playing the role of Bazel's +// Action message: a mnemonic naming the kind of work plus the complete set of +// inputs that determine its outputs. Two actions with the same digest are +// assumed to produce the same outputs. +// +// Inputs are hashed incrementally with length-prefixed framing so that the +// boundary between inputs is unambiguous ("ab"+"c" hashes differently from +// "a"+"bc"). +type Action struct { + hasher *blake3.Hasher +} + +// NewAction starts building an action key for the given mnemonic, e.g. +// "QueryAnalysis" or "FetchPlugin". +func NewAction(mnemonic string) *Action { + a := &Action{hasher: blake3.New(32, nil)} + a.write([]byte(mnemonic)) + return a +} + +// AddInput mixes a named input into the action key. Order matters: callers +// must add inputs in a deterministic order. +func (a *Action) AddInput(name string, data []byte) *Action { + a.write([]byte(name)) + a.write(data) + return a +} + +// Digest returns the action's digest, used as the action cache key. +func (a *Action) Digest() Digest { + var sum [32]byte + a.hasher.Sum(sum[:0]) + return Digest{ + Hash: hex.EncodeToString(sum[:]), + SizeBytes: 0, + } +} + +func (a *Action) write(data []byte) { + var frame [8]byte + binary.LittleEndian.PutUint64(frame[:], uint64(len(data))) + a.hasher.Write(frame[:]) + a.hasher.Write(data) +} diff --git a/internal/ext/wasm/wasm.go b/internal/ext/wasm/wasm.go index 7653676493..bf60a60ae9 100644 --- a/internal/ext/wasm/wasm.go +++ b/internal/ext/wasm/wasm.go @@ -10,7 +10,6 @@ import ( "log/slog" "net/http" "os" - "path/filepath" "runtime" "strings" @@ -55,12 +54,12 @@ func (r *Runner) loadAndCompile(ctx context.Context) (*runtimeAndCode, error) { if err != nil { return nil, err } - cacheDir, err := cache.PluginsDir() + store, err := cache.Open() if err != nil { return nil, err } value, err, _ := flight.Do(expected, func() (any, error) { - return r.loadAndCompileWASM(ctx, cacheDir, expected) + return r.loadAndCompileWASM(ctx, store, expected) }) if err != nil { return nil, err @@ -113,36 +112,61 @@ func (r *Runner) fetch(ctx context.Context, uri string) ([]byte, string, error) return wmod, actual, nil } -func (r *Runner) loadAndCompileWASM(ctx context.Context, cache string, expected string) (*runtimeAndCode, error) { - pluginDir := filepath.Join(cache, expected) - pluginPath := filepath.Join(pluginDir, "plugin.wasm") - _, staterr := os.Stat(pluginPath) - - uri := r.URL - if staterr == nil { - uri = "file://" + pluginPath - } - - wmod, actual, err := r.fetch(ctx, uri) - if err != nil { - return nil, err - } - - if expected != actual { - return nil, fmt.Errorf("invalid checksum: expected %s, got %s", expected, actual) +// The name of the sole output blob a FetchPlugin action produces. +const pluginOutput = "plugin.wasm" + +func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, expected string) (*runtimeAndCode, error) { + // Fetching a plugin is an action whose inputs are the URL and the + // expected sha256 declared in sqlc's configuration. Its result points at + // the plugin binary in the CAS. + actionDigest := cache.NewAction("FetchPlugin"). + AddInput("url", []byte(r.URL)). + AddInput("sha256", []byte(expected)). + Digest() + + var wmod []byte + if cached, err := store.Actions.Get(actionDigest); err == nil { + wmod, err = store.CAS.Get(cached.Outputs[pluginOutput]) + if err != nil && !errors.Is(err, cache.ErrNotFound) { + return nil, err + } } - if staterr != nil { - err := os.Mkdir(pluginDir, 0755) - if err != nil && !os.IsExist(err) { - return nil, fmt.Errorf("mkdirall: %w", err) + if wmod == nil { + var actual string + var err error + wmod, actual, err = r.fetch(ctx, r.URL) + if err != nil { + return nil, err } - if err := os.WriteFile(pluginPath, wmod, 0444); err != nil { + if expected != actual { + return nil, fmt.Errorf("invalid checksum: expected %s, got %s", expected, actual) + } + blob, err := store.CAS.Put(wmod) + if err != nil { + return nil, fmt.Errorf("cache wasm: %w", err) + } + err = store.Actions.Put(actionDigest, &cache.ActionResult{ + Outputs: map[string]cache.Digest{pluginOutput: blob}, + }) + if err != nil { return nil, fmt.Errorf("cache wasm: %w", err) } + } else { + // The CAS verified the blob against its BLAKE3 digest; re-check the + // user-declared sha256 as well so the supply-chain guarantee never + // depends on cache state. + sum := sha256.Sum256(wmod) + if actual := fmt.Sprintf("%x", sum); expected != actual { + return nil, fmt.Errorf("invalid checksum: expected %s, got %s", expected, actual) + } } - wazeroCache, err := wazero.NewCompilationCacheWithDir(filepath.Join(cache, "wazero")) + wazeroDir, err := cache.WazeroDir() + if err != nil { + return nil, err + } + wazeroCache, err := wazero.NewCompilationCacheWithDir(wazeroDir) if err != nil { return nil, fmt.Errorf("wazero.NewCompilationCacheWithDir: %w", err) } From eb94686dc8e059050952287dbdc0e3b2ec913c47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 13:17:19 +0000 Subject: [PATCH 2/9] cache: load remote fetches directly into the CAS by declared checksum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A remote fetch with a declared sha256 doesn't need the action cache: the checksum is itself a content address. Make the CAS hash-function aware — blobs live at cas///, keyed by BLAKE3 by default — and add PutSHA256/SHA256Digest so remotely fetched content is stored and looked up directly under the checksum declared in the configuration, with the checksum re-verified on every read. The WASM plugin path drops its FetchPlugin action entirely; the action cache now backs only work whose outputs aren't derivable from its inputs, like query analysis. This mirrors Bazel, where downloads go through the checksum-addressed repository cache rather than the action cache. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MPgq3rwip76D554BktbqR4 --- docs/reference/environment-variables.md | 11 ++-- internal/cache/cache.go | 16 ++++-- internal/cache/cache_test.go | 59 ++++++++++++++++++-- internal/cache/cas.go | 55 +++++++++++++------ internal/cache/digest.go | 73 +++++++++++++++++++++---- internal/ext/wasm/wasm.go | 46 +++------------- 6 files changed, 180 insertions(+), 80 deletions(-) diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index bb54a4439f..1fdd1a899f 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -25,12 +25,13 @@ Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec- The cache is designed after Bazel's local disk cache and has three parts: -- `cas/` — a content-addressable store holding blobs (WASM plugin binaries, - query analysis results) keyed by the BLAKE3 hash of their contents. +- `cas/` — a content-addressable store holding blobs keyed by the hash of + their contents. Query analysis results are keyed by BLAKE3; remotely + fetched WASM plugins are keyed by the sha256 checksum declared in the + configuration file, so they are loaded directly by that address. - `ac/` — an action cache mapping the digest of a unit of cacheable work and - its inputs (for example, fetching a plugin from a URL with an expected - checksum, or analyzing a query against a schema) to the CAS digests of its - outputs. + its inputs (for example, analyzing a query against a schema) to the CAS + digests of its outputs. - `wazero/` — compiled WASM machine code, managed by the [wazero](https://wazero.io) runtime in its own format. diff --git a/internal/cache/cache.go b/internal/cache/cache.go index ca64ee3c8a..3450beba7b 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -3,16 +3,20 @@ // // It has two halves: // -// - A content-addressable store (CAS) holding blobs keyed by the BLAKE3 -// hash of their contents, laid out as cas//. +// - A content-addressable store (CAS) holding blobs keyed by the hash of +// their contents — BLAKE3 by default — laid out as +// cas///. // - An action cache (AC) mapping the digest of an action — a description of // cacheable work and all of its inputs — to the digests of the outputs // that work produced, laid out as ac//. // -// Executing cached work therefore takes two steps, just like Bazel: hash the -// action, look its digest up in the action cache, then fetch the referenced -// output blobs from the CAS. Both the query analysis cache and the WASM -// plugin cache are built on this pair. +// Work whose output is not derivable from its inputs, like query analysis, +// uses both halves, just like Bazel: hash the action, look its digest up in +// the action cache, then fetch the referenced output blobs from the CAS. +// +// Remote fetches with a declared checksum, like WASM plugins, need no action +// cache entry at all: the declared sha256 is itself a content address, so the +// blob is stored and loaded directly from the CAS keyed by that checksum. package cache import ( diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index e020b10803..ad38ad154a 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -1,9 +1,10 @@ package cache import ( + "crypto/sha256" + "encoding/hex" "errors" "os" - "path/filepath" "testing" ) @@ -63,7 +64,7 @@ func TestCASCorruptionEvicted(t *testing.T) { } // Corrupt the blob on disk while keeping its size. - path := filepath.Join(c.CAS.root, "cas", d.Hash[:2], d.Hash) + path := c.CAS.path(d) if err := os.WriteFile(path, []byte("tampered contents"), 0644); err != nil { t.Fatal(err) } @@ -86,7 +87,7 @@ func TestCASPutRepairsCorruptEntry(t *testing.T) { // Corrupt the entry on disk with different-sized contents, then Put the // correct blob again: the entry must be repaired, not trusted. - path := filepath.Join(c.CAS.root, "cas", d.Hash[:2], d.Hash) + path := c.CAS.path(d) if err := os.WriteFile(path, append(blob, "tampered"...), 0644); err != nil { t.Fatal(err) } @@ -102,6 +103,56 @@ func TestCASPutRepairsCorruptEntry(t *testing.T) { } } +func TestCASSHA256DirectLoad(t *testing.T) { + c := testCache(t) + blob := []byte("fetched plugin binary") + s := sha256.Sum256(blob) + declared := hex.EncodeToString(s[:]) + + // A declared checksum whose content was never fetched is a miss. + if _, err := c.CAS.Get(SHA256Digest(declared)); !errors.Is(err, ErrNotFound) { + t.Errorf("want ErrNotFound before Put, got %v", err) + } + + d, err := c.CAS.PutSHA256(blob) + if err != nil { + t.Fatal(err) + } + if d.Hash != declared { + t.Errorf("stored under %s, want declared checksum %s", d.Hash, declared) + } + + // Later runs load the blob using only the checksum from the config file, + // with no size and no action cache entry. + got, err := c.CAS.Get(SHA256Digest(declared)) + if err != nil { + t.Fatal(err) + } + if string(got) != string(blob) { + t.Errorf("got %q, want %q", got, blob) + } + + // sha256- and blake3-keyed entries live in separate namespaces. + if c.CAS.Contains(DigestOf(blob)) { + t.Error("blob stored by sha256 must not be visible under its blake3 digest") + } +} + +func TestCASSHA256CorruptionEvicted(t *testing.T) { + c := testCache(t) + blob := []byte("plugin contents") + d, err := c.CAS.PutSHA256(blob) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(c.CAS.path(d), []byte("tampered!contents"), 0644); err != nil { + t.Fatal(err) + } + if _, err := c.CAS.Get(SHA256Digest(d.Hash)); !errors.Is(err, ErrNotFound) { + t.Errorf("want ErrNotFound for corrupt blob, got %v", err) + } +} + func TestActionDigestFraming(t *testing.T) { a := NewAction("Test").AddInput("query", []byte("ab")).AddInput("schema", []byte("c")) b := NewAction("Test").AddInput("query", []byte("a")).AddInput("schema", []byte("bc")) @@ -157,7 +208,7 @@ func TestActionCacheMissingOutputIsMiss(t *testing.T) { } // Simulate the blob being garbage collected out from under the entry. - if err := os.Remove(filepath.Join(c.CAS.root, "cas", out.Hash[:2], out.Hash)); err != nil { + if err := os.Remove(c.CAS.path(out)); err != nil { t.Fatal(err) } diff --git a/internal/cache/cas.go b/internal/cache/cas.go index 9577a68afa..84f7578b92 100644 --- a/internal/cache/cas.go +++ b/internal/cache/cas.go @@ -10,11 +10,16 @@ import ( // ErrNotFound is returned when a blob or action result is not in the cache. var ErrNotFound = errors.New("cache: not found") -// CAS is an on-disk content-addressable store keyed by BLAKE3, modeled on -// Bazel's disk cache. Blobs live at cas// under the cache root, -// where is the first two hex characters of the hash. Because a blob's -// name is derived from its contents, entries never change once written: -// writers race benignly and readers can detect corruption by re-hashing. +// CAS is an on-disk content-addressable store modeled on Bazel's disk cache. +// Blobs live at cas/// under the cache root, where +// is the first two hex characters of the hash. Because a blob's name is +// derived from its contents, entries never change once written: writers race +// benignly and readers can detect corruption by re-hashing. +// +// Blobs are keyed by BLAKE3 by default. Content with an externally declared +// SHA-256 checksum (remotely fetched plugins) is stored keyed by that +// checksum instead — see PutSHA256 — so fetches need no action cache entry: +// the declared checksum is the address. type CAS struct { root string tmp string @@ -29,14 +34,25 @@ func newCAS(root string) (*CAS, error) { } func (c *CAS) path(d Digest) string { - return filepath.Join(c.root, "cas", d.Hash[:2], d.Hash) + return filepath.Join(c.root, "cas", string(d.function()), d.Hash[:2], d.Hash) } -// Put stores a blob and returns its digest. Writing is atomic: the blob is -// staged in a temp file and renamed into place, so concurrent sqlc processes -// never observe partial entries. +// Put stores a blob keyed by its BLAKE3 hash and returns its digest. func (c *CAS) Put(data []byte) (Digest, error) { - d := DigestOf(data) + return c.put(DigestOf(data), data) +} + +// PutSHA256 stores a blob keyed by its SHA-256 hash and returns its digest. +// Use this for content addressed by a checksum declared outside the cache, +// so later runs can load it with a bare SHA256Digest lookup. +func (c *CAS) PutSHA256(data []byte) (Digest, error) { + hash, _ := sum(SHA256, data) + return c.put(Digest{Function: SHA256, Hash: hash, SizeBytes: int64(len(data))}, data) +} + +// put writes a blob atomically: it is staged in a temp file and renamed into +// place, so concurrent sqlc processes never observe partial entries. +func (c *CAS) put(d Digest, data []byte) (Digest, error) { path := c.path(d) // Skip the write only when an entry of the right size already exists; a // wrong-sized entry is corrupt and is atomically replaced by the rename @@ -65,9 +81,10 @@ func (c *CAS) Put(data []byte) (Digest, error) { return d, nil } -// Get returns the blob for a digest. Contents are re-hashed before being -// returned; a corrupt entry is evicted and reported as ErrNotFound so callers -// simply re-run the action that produced it. +// Get returns the blob for a digest. Contents are re-hashed with the +// digest's hash function before being returned; a corrupt entry is evicted +// and reported as ErrNotFound so callers simply redo the work that produced +// it. func (c *CAS) Get(d Digest) ([]byte, error) { if !d.valid() { return nil, ErrNotFound @@ -80,19 +97,23 @@ func (c *CAS) Get(d Digest) ([]byte, error) { } return nil, fmt.Errorf("cache: %w", err) } - if DigestOf(data) != d { + if hash, _ := sum(d.function(), data); hash != d.Hash { os.Remove(path) return nil, ErrNotFound } return data, nil } -// Contains reports whether a blob with the given digest and size is present. -// It does not verify contents; Get performs full verification. +// Contains reports whether a blob with the given digest is present, checking +// size when the digest carries one. It does not verify contents; Get performs +// full verification. func (c *CAS) Contains(d Digest) bool { if !d.valid() { return false } fi, err := os.Stat(c.path(d)) - return err == nil && fi.Size() == d.SizeBytes + if err != nil { + return false + } + return d.SizeBytes < 0 || fi.Size() == d.SizeBytes } diff --git a/internal/cache/digest.go b/internal/cache/digest.go index 7e5a75a7e8..76ec4481a3 100644 --- a/internal/cache/digest.go +++ b/internal/cache/digest.go @@ -1,6 +1,7 @@ package cache import ( + "crypto/sha256" "encoding/binary" "encoding/hex" "fmt" @@ -8,37 +9,87 @@ import ( "lukechampine.com/blake3" ) -// Digest identifies a blob by its BLAKE3 hash and size, mirroring the Digest -// message from Bazel's remote execution API. The size is stored alongside the -// hash so that entries can be validated without reading blob contents. +// HashFunc identifies the hash function that keys a blob in the CAS. +// +// BLAKE3 is the native digest function. SHA256 exists so that content with an +// externally declared SHA-256 checksum — like a WASM plugin's checksum in +// sqlc's configuration — can be addressed directly by that checksum without +// any translation table. +type HashFunc string + +const ( + BLAKE3 HashFunc = "blake3" + SHA256 HashFunc = "sha256" +) + +func sum(fn HashFunc, data []byte) (string, bool) { + switch fn { + case BLAKE3: + s := blake3.Sum256(data) + return hex.EncodeToString(s[:]), true + case SHA256: + s := sha256.Sum256(data) + return hex.EncodeToString(s[:]), true + } + return "", false +} + +// Digest identifies a blob by hash function, hash, and size, mirroring the +// Digest message from Bazel's remote execution API. The size is stored +// alongside the hash so that entries can be validated without reading blob +// contents; a negative size means the size is unknown, as with a checksum +// declared in a configuration file. type Digest struct { - // Hash is the lowercase hex-encoded BLAKE3-256 hash of the blob. + // Function is the hash function; an empty value means BLAKE3. + Function HashFunc `json:"function,omitempty"` + // Hash is the lowercase hex-encoded 256-bit hash of the blob. Hash string `json:"hash"` - // SizeBytes is the length of the blob in bytes. + // SizeBytes is the length of the blob in bytes, or negative if unknown. SizeBytes int64 `json:"size_bytes"` } func (d Digest) String() string { - return fmt.Sprintf("blake3:%s/%d", d.Hash, d.SizeBytes) + return fmt.Sprintf("%s:%s/%d", d.function(), d.Hash, d.SizeBytes) +} + +func (d Digest) function() HashFunc { + if d.Function == "" { + return BLAKE3 + } + return d.Function } func (d Digest) valid() bool { - if len(d.Hash) != 64 || d.SizeBytes < 0 { + if _, ok := sum(d.function(), nil); !ok { + return false + } + if len(d.Hash) != 64 { return false } _, err := hex.DecodeString(d.Hash) return err == nil } -// DigestOf returns the Digest of a blob. +// DigestOf returns the BLAKE3 Digest of a blob. func DigestOf(data []byte) Digest { - sum := blake3.Sum256(data) + hash, _ := sum(BLAKE3, data) return Digest{ - Hash: hex.EncodeToString(sum[:]), + Hash: hash, SizeBytes: int64(len(data)), } } +// SHA256Digest returns a Digest referencing a blob by a declared SHA-256 +// checksum whose size is not known, suitable for looking up remotely fetched +// content in the CAS. +func SHA256Digest(hexhash string) Digest { + return Digest{ + Function: SHA256, + Hash: hexhash, + SizeBytes: -1, + } +} + // An Action describes a unit of cacheable work, playing the role of Bazel's // Action message: a mnemonic naming the kind of work plus the complete set of // inputs that determine its outputs. Two actions with the same digest are @@ -52,7 +103,7 @@ type Action struct { } // NewAction starts building an action key for the given mnemonic, e.g. -// "QueryAnalysis" or "FetchPlugin". +// "QueryAnalysis". func NewAction(mnemonic string) *Action { a := &Action{hasher: blake3.New(32, nil)} a.write([]byte(mnemonic)) diff --git a/internal/ext/wasm/wasm.go b/internal/ext/wasm/wasm.go index bf60a60ae9..dd8b4c9620 100644 --- a/internal/ext/wasm/wasm.go +++ b/internal/ext/wasm/wasm.go @@ -112,29 +112,14 @@ func (r *Runner) fetch(ctx context.Context, uri string) ([]byte, string, error) return wmod, actual, nil } -// The name of the sole output blob a FetchPlugin action produces. -const pluginOutput = "plugin.wasm" - func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, expected string) (*runtimeAndCode, error) { - // Fetching a plugin is an action whose inputs are the URL and the - // expected sha256 declared in sqlc's configuration. Its result points at - // the plugin binary in the CAS. - actionDigest := cache.NewAction("FetchPlugin"). - AddInput("url", []byte(r.URL)). - AddInput("sha256", []byte(expected)). - Digest() - - var wmod []byte - if cached, err := store.Actions.Get(actionDigest); err == nil { - wmod, err = store.CAS.Get(cached.Outputs[pluginOutput]) - if err != nil && !errors.Is(err, cache.ErrNotFound) { - return nil, err - } - } - - if wmod == nil { + // The sha256 declared in sqlc's configuration is a content address, so + // the plugin binary is looked up in the CAS directly by that checksum — + // no action cache entry is needed, and Get re-verifies the checksum on + // every hit. + wmod, err := store.CAS.Get(cache.SHA256Digest(expected)) + if errors.Is(err, cache.ErrNotFound) { var actual string - var err error wmod, actual, err = r.fetch(ctx, r.URL) if err != nil { return nil, err @@ -142,24 +127,11 @@ func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, exp if expected != actual { return nil, fmt.Errorf("invalid checksum: expected %s, got %s", expected, actual) } - blob, err := store.CAS.Put(wmod) - if err != nil { - return nil, fmt.Errorf("cache wasm: %w", err) - } - err = store.Actions.Put(actionDigest, &cache.ActionResult{ - Outputs: map[string]cache.Digest{pluginOutput: blob}, - }) - if err != nil { + if _, err := store.CAS.PutSHA256(wmod); err != nil { return nil, fmt.Errorf("cache wasm: %w", err) } - } else { - // The CAS verified the blob against its BLAKE3 digest; re-check the - // user-declared sha256 as well so the supply-chain guarantee never - // depends on cache state. - sum := sha256.Sum256(wmod) - if actual := fmt.Sprintf("%x", sum); expected != actual { - return nil, fmt.Errorf("invalid checksum: expected %s, got %s", expected, actual) - } + } else if err != nil { + return nil, err } wazeroDir, err := cache.WazeroDir() From 5bdddfe9d85e6182a1bc61814bfe64d8975109d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 15:09:25 +0000 Subject: [PATCH 3/9] cache: use SHA-256 as the only digest function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache doesn't hold anything big enough for hash speed to matter, so drop BLAKE3 (and the lukechampine.com/blake3 dependency) and key everything — CAS blobs, action digests — by SHA-256. The CAS layout flattens back to cas//, and a remotely fetched plugin's address is now literally the checksum declared in the configuration file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MPgq3rwip76D554BktbqR4 --- docs/reference/environment-variables.md | 9 ++-- go.mod | 2 - go.sum | 4 -- internal/cache/cache.go | 5 +- internal/cache/cache_test.go | 24 +--------- internal/cache/cas.go | 47 +++++++------------ internal/cache/digest.go | 61 ++++--------------------- internal/ext/wasm/wasm.go | 2 +- 8 files changed, 37 insertions(+), 117 deletions(-) diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 1fdd1a899f..f34f856c29 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -25,10 +25,11 @@ Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec- The cache is designed after Bazel's local disk cache and has three parts: -- `cas/` — a content-addressable store holding blobs keyed by the hash of - their contents. Query analysis results are keyed by BLAKE3; remotely - fetched WASM plugins are keyed by the sha256 checksum declared in the - configuration file, so they are loaded directly by that address. +- `cas/` — a content-addressable store holding blobs (query analysis + results, WASM plugin binaries) keyed by the SHA-256 hash of their + contents. A remotely fetched plugin's address is exactly the checksum + declared in the configuration file, so it is loaded directly by that + address. - `ac/` — an action cache mapping the digest of a unit of cacheable work and its inputs (for example, analyzing a query against a schema) to the CAS digests of its outputs. diff --git a/go.mod b/go.mod index 827f82dca7..c7af41a33f 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,6 @@ require ( google.golang.org/grpc v1.83.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 - lukechampine.com/blake3 v1.4.1 modernc.org/sqlite v1.55.0 ) @@ -44,7 +43,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/kr/text v0.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-sqlite3-wasm/v3 v3.2.35303 // indirect diff --git a/go.sum b/go.sum index 0bc8834761..5c65f8e522 100644 --- a/go.sum +++ b/go.sum @@ -47,8 +47,6 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -151,8 +149,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= -lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 3450beba7b..1ebc0fa6b7 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -3,9 +3,8 @@ // // It has two halves: // -// - A content-addressable store (CAS) holding blobs keyed by the hash of -// their contents — BLAKE3 by default — laid out as -// cas///. +// - A content-addressable store (CAS) holding blobs keyed by the SHA-256 +// hash of their contents, laid out as cas//. // - An action cache (AC) mapping the digest of an action — a description of // cacheable work and all of its inputs — to the digests of the outputs // that work produced, laid out as ac//. diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index ad38ad154a..8e3ec76b93 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -103,7 +103,7 @@ func TestCASPutRepairsCorruptEntry(t *testing.T) { } } -func TestCASSHA256DirectLoad(t *testing.T) { +func TestCASDeclaredChecksumLoad(t *testing.T) { c := testCache(t) blob := []byte("fetched plugin binary") s := sha256.Sum256(blob) @@ -114,7 +114,7 @@ func TestCASSHA256DirectLoad(t *testing.T) { t.Errorf("want ErrNotFound before Put, got %v", err) } - d, err := c.CAS.PutSHA256(blob) + d, err := c.CAS.Put(blob) if err != nil { t.Fatal(err) } @@ -131,26 +131,6 @@ func TestCASSHA256DirectLoad(t *testing.T) { if string(got) != string(blob) { t.Errorf("got %q, want %q", got, blob) } - - // sha256- and blake3-keyed entries live in separate namespaces. - if c.CAS.Contains(DigestOf(blob)) { - t.Error("blob stored by sha256 must not be visible under its blake3 digest") - } -} - -func TestCASSHA256CorruptionEvicted(t *testing.T) { - c := testCache(t) - blob := []byte("plugin contents") - d, err := c.CAS.PutSHA256(blob) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(c.CAS.path(d), []byte("tampered!contents"), 0644); err != nil { - t.Fatal(err) - } - if _, err := c.CAS.Get(SHA256Digest(d.Hash)); !errors.Is(err, ErrNotFound) { - t.Errorf("want ErrNotFound for corrupt blob, got %v", err) - } } func TestActionDigestFraming(t *testing.T) { diff --git a/internal/cache/cas.go b/internal/cache/cas.go index 84f7578b92..60408a240b 100644 --- a/internal/cache/cas.go +++ b/internal/cache/cas.go @@ -10,16 +10,15 @@ import ( // ErrNotFound is returned when a blob or action result is not in the cache. var ErrNotFound = errors.New("cache: not found") -// CAS is an on-disk content-addressable store modeled on Bazel's disk cache. -// Blobs live at cas/// under the cache root, where -// is the first two hex characters of the hash. Because a blob's name is -// derived from its contents, entries never change once written: writers race -// benignly and readers can detect corruption by re-hashing. +// CAS is an on-disk content-addressable store keyed by SHA-256, modeled on +// Bazel's disk cache. Blobs live at cas// under the cache root, +// where is the first two hex characters of the hash. Because a blob's +// name is derived from its contents, entries never change once written: +// writers race benignly and readers can detect corruption by re-hashing. // -// Blobs are keyed by BLAKE3 by default. Content with an externally declared -// SHA-256 checksum (remotely fetched plugins) is stored keyed by that -// checksum instead — see PutSHA256 — so fetches need no action cache entry: -// the declared checksum is the address. +// Content with an externally declared checksum (remotely fetched plugins) +// needs no action cache entry: the declared sha256 is the address, so it is +// stored and loaded directly — see SHA256Digest. type CAS struct { root string tmp string @@ -34,25 +33,14 @@ func newCAS(root string) (*CAS, error) { } func (c *CAS) path(d Digest) string { - return filepath.Join(c.root, "cas", string(d.function()), d.Hash[:2], d.Hash) + return filepath.Join(c.root, "cas", d.Hash[:2], d.Hash) } -// Put stores a blob keyed by its BLAKE3 hash and returns its digest. +// Put stores a blob and returns its digest. Writing is atomic: the blob is +// staged in a temp file and renamed into place, so concurrent sqlc processes +// never observe partial entries. func (c *CAS) Put(data []byte) (Digest, error) { - return c.put(DigestOf(data), data) -} - -// PutSHA256 stores a blob keyed by its SHA-256 hash and returns its digest. -// Use this for content addressed by a checksum declared outside the cache, -// so later runs can load it with a bare SHA256Digest lookup. -func (c *CAS) PutSHA256(data []byte) (Digest, error) { - hash, _ := sum(SHA256, data) - return c.put(Digest{Function: SHA256, Hash: hash, SizeBytes: int64(len(data))}, data) -} - -// put writes a blob atomically: it is staged in a temp file and renamed into -// place, so concurrent sqlc processes never observe partial entries. -func (c *CAS) put(d Digest, data []byte) (Digest, error) { + d := DigestOf(data) path := c.path(d) // Skip the write only when an entry of the right size already exists; a // wrong-sized entry is corrupt and is atomically replaced by the rename @@ -81,10 +69,9 @@ func (c *CAS) put(d Digest, data []byte) (Digest, error) { return d, nil } -// Get returns the blob for a digest. Contents are re-hashed with the -// digest's hash function before being returned; a corrupt entry is evicted -// and reported as ErrNotFound so callers simply redo the work that produced -// it. +// Get returns the blob for a digest. Contents are re-hashed before being +// returned; a corrupt entry is evicted and reported as ErrNotFound so +// callers simply redo the work that produced it. func (c *CAS) Get(d Digest) ([]byte, error) { if !d.valid() { return nil, ErrNotFound @@ -97,7 +84,7 @@ func (c *CAS) Get(d Digest) ([]byte, error) { } return nil, fmt.Errorf("cache: %w", err) } - if hash, _ := sum(d.function(), data); hash != d.Hash { + if DigestOf(data).Hash != d.Hash { os.Remove(path) return nil, ErrNotFound } diff --git a/internal/cache/digest.go b/internal/cache/digest.go index 76ec4481a3..7f10908a7d 100644 --- a/internal/cache/digest.go +++ b/internal/cache/digest.go @@ -5,64 +5,26 @@ import ( "encoding/binary" "encoding/hex" "fmt" - - "lukechampine.com/blake3" -) - -// HashFunc identifies the hash function that keys a blob in the CAS. -// -// BLAKE3 is the native digest function. SHA256 exists so that content with an -// externally declared SHA-256 checksum — like a WASM plugin's checksum in -// sqlc's configuration — can be addressed directly by that checksum without -// any translation table. -type HashFunc string - -const ( - BLAKE3 HashFunc = "blake3" - SHA256 HashFunc = "sha256" + "hash" ) -func sum(fn HashFunc, data []byte) (string, bool) { - switch fn { - case BLAKE3: - s := blake3.Sum256(data) - return hex.EncodeToString(s[:]), true - case SHA256: - s := sha256.Sum256(data) - return hex.EncodeToString(s[:]), true - } - return "", false -} - -// Digest identifies a blob by hash function, hash, and size, mirroring the +// Digest identifies a blob by its SHA-256 hash and size, mirroring the // Digest message from Bazel's remote execution API. The size is stored // alongside the hash so that entries can be validated without reading blob // contents; a negative size means the size is unknown, as with a checksum // declared in a configuration file. type Digest struct { - // Function is the hash function; an empty value means BLAKE3. - Function HashFunc `json:"function,omitempty"` - // Hash is the lowercase hex-encoded 256-bit hash of the blob. + // Hash is the lowercase hex-encoded SHA-256 hash of the blob. Hash string `json:"hash"` // SizeBytes is the length of the blob in bytes, or negative if unknown. SizeBytes int64 `json:"size_bytes"` } func (d Digest) String() string { - return fmt.Sprintf("%s:%s/%d", d.function(), d.Hash, d.SizeBytes) -} - -func (d Digest) function() HashFunc { - if d.Function == "" { - return BLAKE3 - } - return d.Function + return fmt.Sprintf("sha256:%s/%d", d.Hash, d.SizeBytes) } func (d Digest) valid() bool { - if _, ok := sum(d.function(), nil); !ok { - return false - } if len(d.Hash) != 64 { return false } @@ -70,11 +32,11 @@ func (d Digest) valid() bool { return err == nil } -// DigestOf returns the BLAKE3 Digest of a blob. +// DigestOf returns the Digest of a blob. func DigestOf(data []byte) Digest { - hash, _ := sum(BLAKE3, data) + sum := sha256.Sum256(data) return Digest{ - Hash: hash, + Hash: hex.EncodeToString(sum[:]), SizeBytes: int64(len(data)), } } @@ -84,7 +46,6 @@ func DigestOf(data []byte) Digest { // content in the CAS. func SHA256Digest(hexhash string) Digest { return Digest{ - Function: SHA256, Hash: hexhash, SizeBytes: -1, } @@ -99,13 +60,13 @@ func SHA256Digest(hexhash string) Digest { // boundary between inputs is unambiguous ("ab"+"c" hashes differently from // "a"+"bc"). type Action struct { - hasher *blake3.Hasher + hasher hash.Hash } // NewAction starts building an action key for the given mnemonic, e.g. // "QueryAnalysis". func NewAction(mnemonic string) *Action { - a := &Action{hasher: blake3.New(32, nil)} + a := &Action{hasher: sha256.New()} a.write([]byte(mnemonic)) return a } @@ -120,10 +81,8 @@ func (a *Action) AddInput(name string, data []byte) *Action { // Digest returns the action's digest, used as the action cache key. func (a *Action) Digest() Digest { - var sum [32]byte - a.hasher.Sum(sum[:0]) return Digest{ - Hash: hex.EncodeToString(sum[:]), + Hash: hex.EncodeToString(a.hasher.Sum(nil)), SizeBytes: 0, } } diff --git a/internal/ext/wasm/wasm.go b/internal/ext/wasm/wasm.go index dd8b4c9620..9941efb918 100644 --- a/internal/ext/wasm/wasm.go +++ b/internal/ext/wasm/wasm.go @@ -127,7 +127,7 @@ func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, exp if expected != actual { return nil, fmt.Errorf("invalid checksum: expected %s, got %s", expected, actual) } - if _, err := store.CAS.PutSHA256(wmod); err != nil { + if _, err := store.CAS.Put(wmod); err != nil { return nil, fmt.Errorf("cache wasm: %w", err) } } else if err != nil { From 687ebb651c73f9f4d2327abe686c234fb914c9c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 15:16:16 +0000 Subject: [PATCH 4/9] 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/, 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 Claude-Session: https://claude.ai/code/session_01MPgq3rwip76D554BktbqR4 --- docs/reference/environment-variables.md | 17 +++--- internal/cache/action.go | 76 +++++++++++++++++++++++++ internal/cache/cache.go | 18 +++--- internal/cache/cache_test.go | 58 +++++++++++++++++++ internal/ext/wasm/wasm.go | 47 ++++++++++++++- 5 files changed, 196 insertions(+), 20 deletions(-) diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index f34f856c29..291c578c02 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -26,15 +26,16 @@ Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec- The cache is designed after Bazel's local disk cache and has three parts: - `cas/` — a content-addressable store holding blobs (query analysis - results, WASM plugin binaries) keyed by the SHA-256 hash of their - contents. A remotely fetched plugin's address is exactly the checksum - declared in the configuration file, so it is loaded directly by that - address. + results, WASM plugin binaries, compiled WASM machine code) keyed by the + SHA-256 hash of their contents. A remotely fetched plugin's address is + exactly the checksum declared in the configuration file, so it is loaded + directly by that address. - `ac/` — an action cache mapping the digest of a unit of cacheable work and - its inputs (for example, analyzing a query against a schema) to the CAS - digests of its outputs. -- `wazero/` — compiled WASM machine code, managed by the - [wazero](https://wazero.io) runtime in its own format. + its inputs (analyzing a query against a schema, compiling a WASM module to + machine code) to the CAS digests of its outputs. +- `exec/` — per-action directories where cached output trees are + materialized for tools that read them from disk, such as the + [wazero](https://wazero.io) runtime's compilation cache. The entire directory is safe to delete at any time; sqlc will rebuild it as needed. diff --git a/internal/cache/action.go b/internal/cache/action.go index 67a1a4f17c..61d1f35618 100644 --- a/internal/cache/action.go +++ b/internal/cache/action.go @@ -3,6 +3,7 @@ package cache import ( "encoding/json" "fmt" + "io/fs" "os" "path/filepath" ) @@ -63,6 +64,81 @@ func (a *ActionCache) Get(action Digest) (*ActionResult, error) { return &result, nil } +// PutTree stores every file under dir in the CAS and records them as the +// action's outputs, named by their paths relative to dir. Use this for +// actions whose tool writes an output directory, like WASM compilation. +func (a *ActionCache) PutTree(action Digest, dir string) error { + outputs := map[string]Digest{} + err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + d, err := a.cas.Put(data) + if err != nil { + return err + } + outputs[filepath.ToSlash(rel)] = d + return nil + }) + if err != nil { + return fmt.Errorf("cache: %w", err) + } + if len(outputs) == 0 { + return fmt.Errorf("cache: no outputs found under %s", dir) + } + return a.Put(action, &ActionResult{Outputs: outputs}) +} + +// GetTree materializes a cached action's outputs as files under dir, or +// returns ErrNotFound on a miss. Files already present with the right size +// are left in place; missing ones are staged and renamed so concurrent +// processes never observe partial files. +func (a *ActionCache) GetTree(action Digest, dir string) error { + result, err := a.Get(action) + if err != nil { + return err + } + for rel, d := range result.Outputs { + path := filepath.Join(dir, filepath.FromSlash(rel)) + if fi, err := os.Stat(path); err == nil && fi.Size() == d.SizeBytes { + continue + } + data, err := a.cas.Get(d) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("cache: %w", err) + } + f, err := os.CreateTemp(a.cas.tmp, d.Hash[:8]+"-*") + if err != nil { + return fmt.Errorf("cache: %w", err) + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(f.Name()) + return fmt.Errorf("cache: %w", err) + } + if err := f.Close(); err != nil { + os.Remove(f.Name()) + return fmt.Errorf("cache: %w", err) + } + if err := os.Rename(f.Name(), path); err != nil { + os.Remove(f.Name()) + return fmt.Errorf("cache: %w", err) + } + } + return nil +} + // Put records the result of an action. All outputs must already be in the // CAS; writes are staged and renamed so concurrent processes never observe a // partial entry. diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 1ebc0fa6b7..c855319e29 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -26,6 +26,7 @@ import ( // Cache bundles the CAS and the action cache that shares it. type Cache struct { + root string CAS *CAS Actions *ActionCache } @@ -61,20 +62,19 @@ func OpenAt(root string) (*Cache, error) { return nil, err } return &Cache{ + root: root, CAS: cas, Actions: newActionCache(root, cas), }, nil } -// WazeroDir returns the directory for wazero's compilation cache. Compiled -// module machine code is managed by wazero in its own format, so it lives -// beside the CAS rather than inside it. -func WazeroDir() (string, error) { - root, err := Dir() - if err != nil { - return "", err - } - dir := filepath.Join(root, "wazero") +// ExecDir returns a stable directory for materializing the output tree of +// the given action, for tools that need their outputs on disk (like wazero's +// compilation cache). It lives at exec/ under the cache root +// and, like everything else in the cache, is safe to delete at any time: the +// authoritative copy of its contents is the CAS. +func (c *Cache) ExecDir(action Digest) (string, error) { + dir := filepath.Join(c.root, "exec", action.Hash) if err := os.MkdirAll(dir, 0755); err != nil { return "", fmt.Errorf("failed to create %s directory: %w", dir, err) } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 8e3ec76b93..e00126ddad 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "errors" "os" + "path/filepath" "testing" ) @@ -197,6 +198,63 @@ func TestActionCacheMissingOutputIsMiss(t *testing.T) { } } +func TestActionCacheTreeRoundTrip(t *testing.T) { + c := testCache(t) + + // Simulate a tool writing an output directory, like wazero's + // compilation cache. + src := t.TempDir() + files := map[string]string{ + "wazero-v1-amd64-linux/compiled": "machine code", + "manifest": "meta", + } + for rel, contents := range files { + path := filepath.Join(src, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + + action := NewAction("CompileModule").AddInput("wasm", []byte("checksum")).Digest() + if err := c.Actions.GetTree(action, t.TempDir()); !errors.Is(err, ErrNotFound) { + t.Errorf("want ErrNotFound before PutTree, got %v", err) + } + if err := c.Actions.PutTree(action, src); err != nil { + t.Fatal(err) + } + + // Materialize into a fresh directory and compare contents. + dst := t.TempDir() + if err := c.Actions.GetTree(action, dst); err != nil { + t.Fatal(err) + } + for rel, contents := range files { + got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + if string(got) != contents { + t.Errorf("%s: got %q, want %q", rel, got, contents) + } + } + + // Materializing again over the same directory is a no-op. + if err := c.Actions.GetTree(action, dst); err != nil { + t.Fatal(err) + } +} + +func TestActionCachePutTreeRejectsEmptyDir(t *testing.T) { + c := testCache(t) + action := NewAction("CompileModule").Digest() + if err := c.Actions.PutTree(action, t.TempDir()); err == nil { + t.Error("PutTree must reject a directory with no files") + } +} + func TestActionCachePutRejectsMissingOutput(t *testing.T) { c := testCache(t) action := NewAction("Test").Digest() diff --git a/internal/ext/wasm/wasm.go b/internal/ext/wasm/wasm.go index 9941efb918..7ad1535811 100644 --- a/internal/ext/wasm/wasm.go +++ b/internal/ext/wasm/wasm.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "runtime" + "runtime/debug" "strings" "github.com/tetratelabs/wazero" @@ -134,11 +135,30 @@ func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, exp return nil, err } - wazeroDir, err := cache.WazeroDir() + // Compiling the module to machine code is itself a cacheable action, + // keyed by the module's checksum and everything else that determines the + // generated code. Compiled artifacts are materialized into an exec + // directory for wazero's compilation cache to find; the authoritative + // copies live in the CAS. + compileAction := cache.NewAction("CompileModule"). + AddInput("wasm", []byte(expected)). + AddInput("wazero", []byte(wazeroVersion())). + AddInput("goos", []byte(runtime.GOOS)). + AddInput("goarch", []byte(runtime.GOARCH)). + Digest() + + execDir, err := store.ExecDir(compileAction) if err != nil { return nil, err } - wazeroCache, err := wazero.NewCompilationCacheWithDir(wazeroDir) + compiled := true + if err := store.Actions.GetTree(compileAction, execDir); errors.Is(err, cache.ErrNotFound) { + compiled = false + } else if err != nil { + return nil, err + } + + wazeroCache, err := wazero.NewCompilationCacheWithDir(execDir) if err != nil { return nil, fmt.Errorf("wazero.NewCompilationCacheWithDir: %w", err) } @@ -151,15 +171,36 @@ func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, exp } // Compile the Wasm binary once so that we can skip the entire compilation - // time during instantiation. + // time during instantiation. On an action cache hit this loads the + // materialized machine code instead of compiling. code, err := rt.CompileModule(ctx, wmod) if err != nil { return nil, fmt.Errorf("compile module: %w", err) } + if !compiled { + if err := store.Actions.PutTree(compileAction, execDir); err != nil { + slog.Warn("caching compiled module failed", "err", err) + } + } + return &runtimeAndCode{rt: rt, code: code}, nil } +// wazeroVersion returns the version of the wazero dependency, an input to +// the CompileModule action: its generated machine code changes between +// wazero releases. +func wazeroVersion() string { + if bi, ok := debug.ReadBuildInfo(); ok { + for _, dep := range bi.Deps { + if dep.Path == "github.com/tetratelabs/wazero" { + return dep.Version + } + } + } + return info.Version +} + // removePGCatalog removes the pg_catalog schema from the request. There is a // mysterious (reason unknown) bug with wasm plugins when a large amount of // tables (like there are in the catalog) are sent. From c34bc9b50c39c05102fd833379095618656e23a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 15:21:55 +0000 Subject: [PATCH 5/9] cache: make the sqlc binary an implicit input of every action Version strings under-invalidate: every dev build reports the same version, so a rebuilt sqlc with different analysis or codegen logic would keep hitting entries produced by the old binary. Hash the running executable once per process and mix it into every action digest as the first input, the way Bazel treats the toolchain as action input. This subsumes the explicit version, wazero version, and GOOS/GOARCH inputs, which are all determined by the binary, so drop them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MPgq3rwip76D554BktbqR4 --- internal/analyzer/analyzer.go | 7 +++---- internal/cache/cache_test.go | 12 ++++++++++++ internal/cache/digest.go | 6 +++++- internal/cache/tool.go | 32 ++++++++++++++++++++++++++++++++ internal/ext/wasm/wasm.go | 29 ++++++----------------------- 5 files changed, 58 insertions(+), 28 deletions(-) create mode 100644 internal/cache/tool.go diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 0db94f5ed0..71578e4df1 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -11,7 +11,6 @@ import ( "github.com/sqlc-dev/sqlc/internal/analysis" "github.com/sqlc-dev/sqlc/internal/cache" "github.com/sqlc-dev/sqlc/internal/config" - "github.com/sqlc-dev/sqlc/internal/info" "github.com/sqlc-dev/sqlc/internal/sql/ast" "github.com/sqlc-dev/sqlc/internal/sql/named" ) @@ -66,10 +65,10 @@ func (c *CachedAnalyzer) analyze(ctx context.Context, n ast.Node, q string, sche } } - // Analyzing a query is an action whose inputs are the sqlc version, the - // configuration, the schema migrations, and the query itself. + // Analyzing a query is an action whose inputs are the configuration, the + // schema migrations, and the query itself. (The sqlc binary is an + // implicit input of every action.) action := cache.NewAction("QueryAnalysis"). - AddInput("version", []byte(info.Version)). AddInput("config", c.configBytes) for _, m := range schema { action.AddInput("schema", []byte(m)) diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index e00126ddad..c832882940 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -134,6 +134,18 @@ func TestCASDeclaredChecksumLoad(t *testing.T) { } } +func TestToolDigest(t *testing.T) { + d := toolDigest() + if len(d) == 0 { + t.Fatal("toolDigest returned no bytes") + } + // The digest is hashed once and must be stable within a process, or + // identical actions would stop matching. + if string(toolDigest()) != string(d) { + t.Error("toolDigest is not stable across calls") + } +} + func TestActionDigestFraming(t *testing.T) { a := NewAction("Test").AddInput("query", []byte("ab")).AddInput("schema", []byte("c")) b := NewAction("Test").AddInput("query", []byte("a")).AddInput("schema", []byte("bc")) diff --git a/internal/cache/digest.go b/internal/cache/digest.go index 7f10908a7d..552cdbb066 100644 --- a/internal/cache/digest.go +++ b/internal/cache/digest.go @@ -64,10 +64,14 @@ type Action struct { } // NewAction starts building an action key for the given mnemonic, e.g. -// "QueryAnalysis". +// "QueryAnalysis". The sha256 of the sqlc binary itself is always the first +// input: the tool that executes an action determines its outputs just as +// much as the declared inputs do, so a rebuilt sqlc never reuses stale +// entries. func NewAction(mnemonic string) *Action { a := &Action{hasher: sha256.New()} a.write([]byte(mnemonic)) + a.AddInput("tool", toolDigest()) return a } diff --git a/internal/cache/tool.go b/internal/cache/tool.go new file mode 100644 index 0000000000..75bf3a743f --- /dev/null +++ b/internal/cache/tool.go @@ -0,0 +1,32 @@ +package cache + +import ( + "crypto/sha256" + "io" + "os" + "sync" + + "github.com/sqlc-dev/sqlc/internal/info" +) + +// toolDigest returns the sha256 of the running sqlc binary, hashed once per +// process. The binary is an input to every action — a rebuilt sqlc may +// analyze queries or embed a different wazero than the one that produced a +// cache entry, even when the version string is unchanged (dev builds). If +// the executable can't be read, fall back to the version string. +var toolDigest = sync.OnceValue(func() []byte { + path, err := os.Executable() + if err != nil { + return []byte(info.Version) + } + f, err := os.Open(path) + if err != nil { + return []byte(info.Version) + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return []byte(info.Version) + } + return h.Sum(nil) +}) diff --git a/internal/ext/wasm/wasm.go b/internal/ext/wasm/wasm.go index 7ad1535811..5439003bf6 100644 --- a/internal/ext/wasm/wasm.go +++ b/internal/ext/wasm/wasm.go @@ -11,7 +11,6 @@ import ( "net/http" "os" "runtime" - "runtime/debug" "strings" "github.com/tetratelabs/wazero" @@ -135,16 +134,14 @@ func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, exp return nil, err } - // Compiling the module to machine code is itself a cacheable action, - // keyed by the module's checksum and everything else that determines the - // generated code. Compiled artifacts are materialized into an exec - // directory for wazero's compilation cache to find; the authoritative - // copies live in the CAS. + // Compiling the module to machine code is itself a cacheable action. + // Its only declared input is the module's checksum: the embedded wazero + // version and the target platform are determined by the sqlc binary, + // which is an implicit input of every action. Compiled artifacts are + // materialized into an exec directory for wazero's compilation cache to + // find; the authoritative copies live in the CAS. compileAction := cache.NewAction("CompileModule"). AddInput("wasm", []byte(expected)). - AddInput("wazero", []byte(wazeroVersion())). - AddInput("goos", []byte(runtime.GOOS)). - AddInput("goarch", []byte(runtime.GOARCH)). Digest() execDir, err := store.ExecDir(compileAction) @@ -187,20 +184,6 @@ func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, exp return &runtimeAndCode{rt: rt, code: code}, nil } -// wazeroVersion returns the version of the wazero dependency, an input to -// the CompileModule action: its generated machine code changes between -// wazero releases. -func wazeroVersion() string { - if bi, ok := debug.ReadBuildInfo(); ok { - for _, dep := range bi.Deps { - if dep.Path == "github.com/tetratelabs/wazero" { - return dep.Version - } - } - } - return info.Version -} - // removePGCatalog removes the pg_catalog schema from the request. There is a // mysterious (reason unknown) bug with wasm plugins when a large amount of // tables (like there are in the catalog) are sent. From c6b4a7741c1fd41b0f82bb54174acf28d8a5db5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 15:32:30 +0000 Subject: [PATCH 6/9] cache: confine storage I/O with os.Root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hold an os.Root for the cache directory instead of a path string in CAS and ActionCache. Every entry read, write, rename, and eviction now goes through the root, so no entry name — hash-derived or deserialized from an action cache entry — can traverse outside the cache directory. Cache handles now hold a directory fd, so Open gains a matching Close: the analyzer opens the cache once per instance and closes it with the analyzer, and the WASM runner closes it after loading a plugin. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MPgq3rwip76D554BktbqR4 --- internal/analyzer/analyzer.go | 15 ++++++++-- internal/cache/action.go | 33 +++++++++++++--------- internal/cache/cache.go | 29 ++++++++++++++----- internal/cache/cache_test.go | 10 ++++--- internal/cache/cas.go | 53 ++++++++++++++++++++++++----------- internal/ext/wasm/wasm.go | 1 + 6 files changed, 96 insertions(+), 45 deletions(-) diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 71578e4df1..b04e29f6c9 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -20,6 +20,7 @@ type CachedAnalyzer struct { config config.Config configBytes []byte db config.Database + store *cache.Cache } func Cached(a Analyzer, c config.Config, db config.Database) *CachedAnalyzer { @@ -53,12 +54,17 @@ func (c *CachedAnalyzer) analyze(ctx context.Context, n ast.Node, q string, sche return nil, true, nil } - store, err := cache.Open() - if err != nil { - return nil, true, err + if c.store == nil { + var err error + c.store, err = cache.Open() + if err != nil { + return nil, true, err + } } + store := c.store if c.configBytes == nil { + var err error c.configBytes, err = json.Marshal(c.config) if err != nil { return nil, true, err @@ -113,6 +119,9 @@ func (c *CachedAnalyzer) analyze(ctx context.Context, n ast.Node, q string, sche } func (c *CachedAnalyzer) Close(ctx context.Context) error { + if c.store != nil { + c.store.Close() + } return c.a.Close(ctx) } diff --git a/internal/cache/action.go b/internal/cache/action.go index 61d1f35618..3db20c15ab 100644 --- a/internal/cache/action.go +++ b/internal/cache/action.go @@ -2,6 +2,7 @@ package cache import ( "encoding/json" + "errors" "fmt" "io/fs" "os" @@ -22,17 +23,21 @@ type ActionResult struct { // entries are not self-validating — the value is not derivable from the key — // so Get additionally checks that every referenced output still exists in the // CAS before reporting a hit, exactly like Bazel's disk cache does. +// +// Entry I/O goes through the same os.Root as the CAS, confining every read +// and write to the cache directory. type ActionCache struct { - root string + root *os.Root cas *CAS } -func newActionCache(root string, cas *CAS) *ActionCache { +func newActionCache(root *os.Root, cas *CAS) *ActionCache { return &ActionCache{root: root, cas: cas} } +// path returns an entry's path relative to the cache root. func (a *ActionCache) path(d Digest) string { - return filepath.Join(a.root, "ac", d.Hash[:2], d.Hash) + return filepath.Join("ac", d.Hash[:2], d.Hash) } // Get returns the cached result for an action, or ErrNotFound on a miss. An @@ -43,21 +48,21 @@ func (a *ActionCache) Get(action Digest) (*ActionResult, error) { return nil, ErrNotFound } path := a.path(action) - data, err := os.ReadFile(path) + data, err := a.root.ReadFile(path) if err != nil { - if os.IsNotExist(err) { + if errors.Is(err, fs.ErrNotExist) { return nil, ErrNotFound } return nil, fmt.Errorf("cache: %w", err) } var result ActionResult if err := json.Unmarshal(data, &result); err != nil { - os.Remove(path) + a.root.Remove(path) return nil, ErrNotFound } for _, d := range result.Outputs { if !a.cas.Contains(d) { - os.Remove(path) + a.root.Remove(path) return nil, ErrNotFound } } @@ -99,8 +104,8 @@ func (a *ActionCache) PutTree(action Digest, dir string) error { // GetTree materializes a cached action's outputs as files under dir, or // returns ErrNotFound on a miss. Files already present with the right size -// are left in place; missing ones are staged and renamed so concurrent -// processes never observe partial files. +// are left in place; missing ones are staged in their destination directory +// and renamed so concurrent processes never observe partial files. func (a *ActionCache) GetTree(action Digest, dir string) error { result, err := a.Get(action) if err != nil { @@ -118,7 +123,7 @@ func (a *ActionCache) GetTree(action Digest, dir string) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf("cache: %w", err) } - f, err := os.CreateTemp(a.cas.tmp, d.Hash[:8]+"-*") + f, err := os.CreateTemp(filepath.Dir(path), d.Hash[:8]+"-*") if err != nil { return fmt.Errorf("cache: %w", err) } @@ -153,14 +158,14 @@ func (a *ActionCache) Put(action Digest, result *ActionResult) error { return fmt.Errorf("cache: %w", err) } path := a.path(action) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + if err := a.root.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf("cache: %w", err) } - f, err := os.CreateTemp(a.cas.tmp, action.Hash[:8]+"-*") + f, name, err := a.cas.createTemp(action.Hash[:8] + "-") if err != nil { return fmt.Errorf("cache: %w", err) } - defer os.Remove(f.Name()) + defer a.root.Remove(name) if _, err := f.Write(data); err != nil { f.Close() return fmt.Errorf("cache: %w", err) @@ -168,7 +173,7 @@ func (a *ActionCache) Put(action Digest, result *ActionResult) error { if err := f.Close(); err != nil { return fmt.Errorf("cache: %w", err) } - if err := os.Rename(f.Name(), path); err != nil { + if err := a.root.Rename(name, path); err != nil { return fmt.Errorf("cache: %w", err) } return nil diff --git a/internal/cache/cache.go b/internal/cache/cache.go index c855319e29..2d7e89f3dc 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -24,9 +24,11 @@ import ( "path/filepath" ) -// Cache bundles the CAS and the action cache that shares it. +// Cache bundles the CAS and the action cache that shares it. All storage +// I/O is confined to the cache directory through an os.Root; callers should +// Close the cache when finished with it to release the root. type Cache struct { - root string + root *os.Root CAS *CAS Actions *ActionCache } @@ -56,9 +58,17 @@ func Open() (*Cache, error) { // OpenAt returns the cache rooted at the given directory, creating it if // necessary. -func OpenAt(root string) (*Cache, error) { +func OpenAt(dir string) (*Cache, error) { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create %s directory: %w", dir, err) + } + root, err := os.OpenRoot(dir) + if err != nil { + return nil, fmt.Errorf("cache: %w", err) + } cas, err := newCAS(root) if err != nil { + root.Close() return nil, err } return &Cache{ @@ -68,15 +78,20 @@ func OpenAt(root string) (*Cache, error) { }, nil } +// Close releases the cache's handle on its root directory. +func (c *Cache) Close() error { + return c.root.Close() +} + // ExecDir returns a stable directory for materializing the output tree of // the given action, for tools that need their outputs on disk (like wazero's // compilation cache). It lives at exec/ under the cache root // and, like everything else in the cache, is safe to delete at any time: the // authoritative copy of its contents is the CAS. func (c *Cache) ExecDir(action Digest) (string, error) { - dir := filepath.Join(c.root, "exec", action.Hash) - if err := os.MkdirAll(dir, 0755); err != nil { - return "", fmt.Errorf("failed to create %s directory: %w", dir, err) + rel := filepath.Join("exec", action.Hash) + if err := c.root.MkdirAll(rel, 0755); err != nil { + return "", fmt.Errorf("failed to create %s directory: %w", rel, err) } - return dir, nil + return filepath.Join(c.root.Name(), rel), nil } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index c832882940..d5c56c6724 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "io/fs" "os" "path/filepath" "testing" @@ -15,6 +16,7 @@ func testCache(t *testing.T) *Cache { if err != nil { t.Fatal(err) } + t.Cleanup(func() { c.Close() }) return c } @@ -66,14 +68,14 @@ func TestCASCorruptionEvicted(t *testing.T) { // Corrupt the blob on disk while keeping its size. path := c.CAS.path(d) - if err := os.WriteFile(path, []byte("tampered contents"), 0644); err != nil { + if err := c.CAS.root.WriteFile(path, []byte("tampered contents"), 0644); err != nil { t.Fatal(err) } if _, err := c.CAS.Get(d); !errors.Is(err, ErrNotFound) { t.Errorf("want ErrNotFound for corrupt blob, got %v", err) } - if _, err := os.Stat(path); !os.IsNotExist(err) { + if _, err := c.CAS.root.Stat(path); !errors.Is(err, fs.ErrNotExist) { t.Error("corrupt blob was not evicted") } } @@ -89,7 +91,7 @@ func TestCASPutRepairsCorruptEntry(t *testing.T) { // Corrupt the entry on disk with different-sized contents, then Put the // correct blob again: the entry must be repaired, not trusted. path := c.CAS.path(d) - if err := os.WriteFile(path, append(blob, "tampered"...), 0644); err != nil { + if err := c.CAS.root.WriteFile(path, append(blob, "tampered"...), 0644); err != nil { t.Fatal(err) } if _, err := c.CAS.Put(blob); err != nil { @@ -201,7 +203,7 @@ func TestActionCacheMissingOutputIsMiss(t *testing.T) { } // Simulate the blob being garbage collected out from under the entry. - if err := os.Remove(c.CAS.path(out)); err != nil { + if err := c.CAS.root.Remove(c.CAS.path(out)); err != nil { t.Fatal(err) } diff --git a/internal/cache/cas.go b/internal/cache/cas.go index 60408a240b..c2210d50dc 100644 --- a/internal/cache/cas.go +++ b/internal/cache/cas.go @@ -3,8 +3,11 @@ package cache import ( "errors" "fmt" + "io/fs" + "math/rand/v2" "os" "path/filepath" + "strconv" ) // ErrNotFound is returned when a blob or action result is not in the cache. @@ -19,21 +22,37 @@ var ErrNotFound = errors.New("cache: not found") // Content with an externally declared checksum (remotely fetched plugins) // needs no action cache entry: the declared sha256 is the address, so it is // stored and loaded directly — see SHA256Digest. +// +// All I/O goes through an os.Root, so no entry name — hash-derived or read +// from an action cache entry — can escape the cache directory. type CAS struct { - root string - tmp string + root *os.Root } -func newCAS(root string) (*CAS, error) { - tmp := filepath.Join(root, "tmp") - if err := os.MkdirAll(tmp, 0755); err != nil { - return nil, fmt.Errorf("cache: create %s: %w", tmp, err) +func newCAS(root *os.Root) (*CAS, error) { + if err := root.MkdirAll("tmp", 0755); err != nil { + return nil, fmt.Errorf("cache: create tmp: %w", err) } - return &CAS{root: root, tmp: tmp}, nil + return &CAS{root: root}, nil } +// path returns a blob's path relative to the cache root. func (c *CAS) path(d Digest) string { - return filepath.Join(c.root, "cas", d.Hash[:2], d.Hash) + return filepath.Join("cas", d.Hash[:2], d.Hash) +} + +// createTemp creates a staging file under tmp/ in the cache root, returning +// the open file and its root-relative name. +func (c *CAS) createTemp(prefix string) (*os.File, string, error) { + for range 10000 { + name := filepath.Join("tmp", prefix+strconv.FormatUint(rand.Uint64(), 36)) + f, err := c.root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0644) + if errors.Is(err, fs.ErrExist) { + continue + } + return f, name, err + } + return nil, "", errors.New("cache: could not create temp file") } // Put stores a blob and returns its digest. Writing is atomic: the blob is @@ -45,17 +64,17 @@ func (c *CAS) Put(data []byte) (Digest, error) { // Skip the write only when an entry of the right size already exists; a // wrong-sized entry is corrupt and is atomically replaced by the rename // below. - if fi, err := os.Stat(path); err == nil && fi.Size() == d.SizeBytes { + if fi, err := c.root.Stat(path); err == nil && fi.Size() == d.SizeBytes { return d, nil } - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + if err := c.root.MkdirAll(filepath.Dir(path), 0755); err != nil { return Digest{}, fmt.Errorf("cache: %w", err) } - f, err := os.CreateTemp(c.tmp, d.Hash[:8]+"-*") + f, name, err := c.createTemp(d.Hash[:8] + "-") if err != nil { return Digest{}, fmt.Errorf("cache: %w", err) } - defer os.Remove(f.Name()) + defer c.root.Remove(name) if _, err := f.Write(data); err != nil { f.Close() return Digest{}, fmt.Errorf("cache: %w", err) @@ -63,7 +82,7 @@ func (c *CAS) Put(data []byte) (Digest, error) { if err := f.Close(); err != nil { return Digest{}, fmt.Errorf("cache: %w", err) } - if err := os.Rename(f.Name(), path); err != nil { + if err := c.root.Rename(name, path); err != nil { return Digest{}, fmt.Errorf("cache: %w", err) } return d, nil @@ -77,15 +96,15 @@ func (c *CAS) Get(d Digest) ([]byte, error) { return nil, ErrNotFound } path := c.path(d) - data, err := os.ReadFile(path) + data, err := c.root.ReadFile(path) if err != nil { - if os.IsNotExist(err) { + if errors.Is(err, fs.ErrNotExist) { return nil, ErrNotFound } return nil, fmt.Errorf("cache: %w", err) } if DigestOf(data).Hash != d.Hash { - os.Remove(path) + c.root.Remove(path) return nil, ErrNotFound } return data, nil @@ -98,7 +117,7 @@ func (c *CAS) Contains(d Digest) bool { if !d.valid() { return false } - fi, err := os.Stat(c.path(d)) + fi, err := c.root.Stat(c.path(d)) if err != nil { return false } diff --git a/internal/ext/wasm/wasm.go b/internal/ext/wasm/wasm.go index 5439003bf6..aa74782fc4 100644 --- a/internal/ext/wasm/wasm.go +++ b/internal/ext/wasm/wasm.go @@ -58,6 +58,7 @@ func (r *Runner) loadAndCompile(ctx context.Context) (*runtimeAndCode, error) { if err != nil { return nil, err } + defer store.Close() value, err, _ := flight.Do(expected, func() (any, error) { return r.loadAndCompileWASM(ctx, store, expected) }) From 6b9e17ea7251af809dc22544ad9ac01bedb80b7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 16:35:07 +0000 Subject: [PATCH 7/9] cache: remove cache_test.go Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MPgq3rwip76D554BktbqR4 --- internal/cache/cache_test.go | 281 ----------------------------------- 1 file changed, 281 deletions(-) delete mode 100644 internal/cache/cache_test.go diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go deleted file mode 100644 index d5c56c6724..0000000000 --- a/internal/cache/cache_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package cache - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "io/fs" - "os" - "path/filepath" - "testing" -) - -func testCache(t *testing.T) *Cache { - t.Helper() - c, err := OpenAt(t.TempDir()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { c.Close() }) - return c -} - -func TestCASRoundTrip(t *testing.T) { - c := testCache(t) - blob := []byte("SELECT id, name FROM authors") - - d, err := c.CAS.Put(blob) - if err != nil { - t.Fatal(err) - } - if d != DigestOf(blob) { - t.Errorf("digest mismatch: %s != %s", d, DigestOf(blob)) - } - if !c.CAS.Contains(d) { - t.Error("Contains returned false for stored blob") - } - - got, err := c.CAS.Get(d) - if err != nil { - t.Fatal(err) - } - if string(got) != string(blob) { - t.Errorf("got %q, want %q", got, blob) - } - - // Idempotent re-put - if _, err := c.CAS.Put(blob); err != nil { - t.Fatal(err) - } -} - -func TestCASMiss(t *testing.T) { - c := testCache(t) - if _, err := c.CAS.Get(DigestOf([]byte("never stored"))); !errors.Is(err, ErrNotFound) { - t.Errorf("want ErrNotFound, got %v", err) - } - if c.CAS.Contains(Digest{Hash: "zz", SizeBytes: 1}) { - t.Error("Contains returned true for invalid digest") - } -} - -func TestCASCorruptionEvicted(t *testing.T) { - c := testCache(t) - d, err := c.CAS.Put([]byte("original contents")) - if err != nil { - t.Fatal(err) - } - - // Corrupt the blob on disk while keeping its size. - path := c.CAS.path(d) - if err := c.CAS.root.WriteFile(path, []byte("tampered contents"), 0644); err != nil { - t.Fatal(err) - } - - if _, err := c.CAS.Get(d); !errors.Is(err, ErrNotFound) { - t.Errorf("want ErrNotFound for corrupt blob, got %v", err) - } - if _, err := c.CAS.root.Stat(path); !errors.Is(err, fs.ErrNotExist) { - t.Error("corrupt blob was not evicted") - } -} - -func TestCASPutRepairsCorruptEntry(t *testing.T) { - c := testCache(t) - blob := []byte("plugin bytes") - d, err := c.CAS.Put(blob) - if err != nil { - t.Fatal(err) - } - - // Corrupt the entry on disk with different-sized contents, then Put the - // correct blob again: the entry must be repaired, not trusted. - path := c.CAS.path(d) - if err := c.CAS.root.WriteFile(path, append(blob, "tampered"...), 0644); err != nil { - t.Fatal(err) - } - if _, err := c.CAS.Put(blob); err != nil { - t.Fatal(err) - } - got, err := c.CAS.Get(d) - if err != nil { - t.Fatal(err) - } - if string(got) != string(blob) { - t.Errorf("got %q, want %q", got, blob) - } -} - -func TestCASDeclaredChecksumLoad(t *testing.T) { - c := testCache(t) - blob := []byte("fetched plugin binary") - s := sha256.Sum256(blob) - declared := hex.EncodeToString(s[:]) - - // A declared checksum whose content was never fetched is a miss. - if _, err := c.CAS.Get(SHA256Digest(declared)); !errors.Is(err, ErrNotFound) { - t.Errorf("want ErrNotFound before Put, got %v", err) - } - - d, err := c.CAS.Put(blob) - if err != nil { - t.Fatal(err) - } - if d.Hash != declared { - t.Errorf("stored under %s, want declared checksum %s", d.Hash, declared) - } - - // Later runs load the blob using only the checksum from the config file, - // with no size and no action cache entry. - got, err := c.CAS.Get(SHA256Digest(declared)) - if err != nil { - t.Fatal(err) - } - if string(got) != string(blob) { - t.Errorf("got %q, want %q", got, blob) - } -} - -func TestToolDigest(t *testing.T) { - d := toolDigest() - if len(d) == 0 { - t.Fatal("toolDigest returned no bytes") - } - // The digest is hashed once and must be stable within a process, or - // identical actions would stop matching. - if string(toolDigest()) != string(d) { - t.Error("toolDigest is not stable across calls") - } -} - -func TestActionDigestFraming(t *testing.T) { - a := NewAction("Test").AddInput("query", []byte("ab")).AddInput("schema", []byte("c")) - b := NewAction("Test").AddInput("query", []byte("a")).AddInput("schema", []byte("bc")) - if a.Digest() == b.Digest() { - t.Error("shifting bytes between inputs must change the action digest") - } - - x := NewAction("Test").AddInput("query", []byte("q")) - y := NewAction("Test").AddInput("query", []byte("q")) - if x.Digest() != y.Digest() { - t.Error("identical actions must have identical digests") - } -} - -func TestActionCacheRoundTrip(t *testing.T) { - c := testCache(t) - out, err := c.CAS.Put([]byte("analysis result")) - if err != nil { - t.Fatal(err) - } - - action := NewAction("QueryAnalysis").AddInput("query", []byte("SELECT 1")).Digest() - if _, err := c.Actions.Get(action); !errors.Is(err, ErrNotFound) { - t.Errorf("want ErrNotFound before Put, got %v", err) - } - - if err := c.Actions.Put(action, &ActionResult{ - Outputs: map[string]Digest{"analysis.pb": out}, - }); err != nil { - t.Fatal(err) - } - - result, err := c.Actions.Get(action) - if err != nil { - t.Fatal(err) - } - if result.Outputs["analysis.pb"] != out { - t.Errorf("got %s, want %s", result.Outputs["analysis.pb"], out) - } -} - -func TestActionCacheMissingOutputIsMiss(t *testing.T) { - c := testCache(t) - out, err := c.CAS.Put([]byte("ephemeral")) - if err != nil { - t.Fatal(err) - } - action := NewAction("Test").AddInput("in", []byte("x")).Digest() - if err := c.Actions.Put(action, &ActionResult{ - Outputs: map[string]Digest{"out": out}, - }); err != nil { - t.Fatal(err) - } - - // Simulate the blob being garbage collected out from under the entry. - if err := c.CAS.root.Remove(c.CAS.path(out)); err != nil { - t.Fatal(err) - } - - if _, err := c.Actions.Get(action); !errors.Is(err, ErrNotFound) { - t.Errorf("want ErrNotFound when output blob is gone, got %v", err) - } -} - -func TestActionCacheTreeRoundTrip(t *testing.T) { - c := testCache(t) - - // Simulate a tool writing an output directory, like wazero's - // compilation cache. - src := t.TempDir() - files := map[string]string{ - "wazero-v1-amd64-linux/compiled": "machine code", - "manifest": "meta", - } - for rel, contents := range files { - path := filepath.Join(src, filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(contents), 0644); err != nil { - t.Fatal(err) - } - } - - action := NewAction("CompileModule").AddInput("wasm", []byte("checksum")).Digest() - if err := c.Actions.GetTree(action, t.TempDir()); !errors.Is(err, ErrNotFound) { - t.Errorf("want ErrNotFound before PutTree, got %v", err) - } - if err := c.Actions.PutTree(action, src); err != nil { - t.Fatal(err) - } - - // Materialize into a fresh directory and compare contents. - dst := t.TempDir() - if err := c.Actions.GetTree(action, dst); err != nil { - t.Fatal(err) - } - for rel, contents := range files { - got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(rel))) - if err != nil { - t.Fatal(err) - } - if string(got) != contents { - t.Errorf("%s: got %q, want %q", rel, got, contents) - } - } - - // Materializing again over the same directory is a no-op. - if err := c.Actions.GetTree(action, dst); err != nil { - t.Fatal(err) - } -} - -func TestActionCachePutTreeRejectsEmptyDir(t *testing.T) { - c := testCache(t) - action := NewAction("CompileModule").Digest() - if err := c.Actions.PutTree(action, t.TempDir()); err == nil { - t.Error("PutTree must reject a directory with no files") - } -} - -func TestActionCachePutRejectsMissingOutput(t *testing.T) { - c := testCache(t) - action := NewAction("Test").Digest() - err := c.Actions.Put(action, &ActionResult{ - Outputs: map[string]Digest{"out": DigestOf([]byte("never stored"))}, - }) - if err == nil { - t.Error("Put must reject results whose outputs are not in the CAS") - } -} From 06d3255c1f0b498eb440faac060829308a820b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:31:36 +0000 Subject: [PATCH 8/9] cache: memoize the tool digest keyed by executable metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hashing the ~100MB sqlc binary costs ~80ms, which every short-lived sqlc process paid on its first action. Memoize the digest on disk keyed by the executable's path, size, and mtime — the same trick as Bazel's file digest cache — so a warm run costs a stat and a tiny read, and only a rebuilt or moved binary is re-hashed. This puts warm-cache generate times back on par with the pre-redesign cache (~32ms for a WASM plugin run, previously ~100ms with per-process hashing). The memo lives in the cache, so NewAction moves onto Cache. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MPgq3rwip76D554BktbqR4 --- internal/analyzer/analyzer.go | 2 +- internal/cache/digest.go | 7 ++-- internal/cache/tool.go | 79 +++++++++++++++++++++++++++++++---- internal/ext/wasm/wasm.go | 2 +- 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index b04e29f6c9..357d617235 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -74,7 +74,7 @@ func (c *CachedAnalyzer) analyze(ctx context.Context, n ast.Node, q string, sche // Analyzing a query is an action whose inputs are the configuration, the // schema migrations, and the query itself. (The sqlc binary is an // implicit input of every action.) - action := cache.NewAction("QueryAnalysis"). + action := store.NewAction("QueryAnalysis"). AddInput("config", c.configBytes) for _, m := range schema { action.AddInput("schema", []byte(m)) diff --git a/internal/cache/digest.go b/internal/cache/digest.go index 552cdbb066..4909d466ed 100644 --- a/internal/cache/digest.go +++ b/internal/cache/digest.go @@ -67,11 +67,12 @@ type Action struct { // "QueryAnalysis". The sha256 of the sqlc binary itself is always the first // input: the tool that executes an action determines its outputs just as // much as the declared inputs do, so a rebuilt sqlc never reuses stale -// entries. -func NewAction(mnemonic string) *Action { +// entries. The binary's digest is memoized in the cache — see toolDigest — +// which is why actions are created through a Cache. +func (c *Cache) NewAction(mnemonic string) *Action { a := &Action{hasher: sha256.New()} a.write([]byte(mnemonic)) - a.AddInput("tool", toolDigest()) + a.AddInput("tool", c.toolDigest()) return a } diff --git a/internal/cache/tool.go b/internal/cache/tool.go index 75bf3a743f..51c8c42c2a 100644 --- a/internal/cache/tool.go +++ b/internal/cache/tool.go @@ -2,6 +2,8 @@ package cache import ( "crypto/sha256" + "encoding/hex" + "encoding/json" "io" "os" "sync" @@ -9,16 +11,59 @@ import ( "github.com/sqlc-dev/sqlc/internal/info" ) -// toolDigest returns the sha256 of the running sqlc binary, hashed once per -// process. The binary is an input to every action — a rebuilt sqlc may -// analyze queries or embed a different wazero than the one that produced a -// cache entry, even when the version string is unchanged (dev builds). If -// the executable can't be read, fall back to the version string. -var toolDigest = sync.OnceValue(func() []byte { +// The sha256 of the sqlc binary is an input to every action — a rebuilt sqlc +// may analyze queries or embed a different wazero than the one that produced +// a cache entry, even when the version string is unchanged (dev builds). +// +// Hashing a ~100MB executable costs tens of milliseconds, too much to pay on +// every short-lived sqlc process, so the digest is memoized on disk keyed by +// the executable's path, size, and mtime — the same trick Bazel's file +// digest cache uses. A warm run costs one stat and a tiny read; only a +// rebuilt (or moved) binary is re-hashed. +var tool struct { + sync.Mutex + digest []byte +} + +type toolMemo struct { + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + MtimeNS int64 `json:"mtime_ns"` + SHA256 string `json:"sha256"` +} + +const toolMemoPath = "tool" + +func (c *Cache) toolDigest() []byte { + tool.Lock() + defer tool.Unlock() + if tool.digest == nil { + tool.digest = c.computeToolDigest() + } + return tool.digest +} + +func (c *Cache) computeToolDigest() []byte { path, err := os.Executable() if err != nil { return []byte(info.Version) } + fi, err := os.Stat(path) + if err != nil { + return []byte(info.Version) + } + + var memo toolMemo + if data, err := c.root.ReadFile(toolMemoPath); err == nil { + if err := json.Unmarshal(data, &memo); err == nil && + memo.Path == path && + memo.SizeBytes == fi.Size() && + memo.MtimeNS == fi.ModTime().UnixNano() && + memo.SHA256 != "" { + return []byte(memo.SHA256) + } + } + f, err := os.Open(path) if err != nil { return []byte(info.Version) @@ -28,5 +73,23 @@ var toolDigest = sync.OnceValue(func() []byte { if _, err := io.Copy(h, f); err != nil { return []byte(info.Version) } - return h.Sum(nil) -}) + sum := hex.EncodeToString(h.Sum(nil)) + + memo = toolMemo{ + Path: path, + SizeBytes: fi.Size(), + MtimeNS: fi.ModTime().UnixNano(), + SHA256: sum, + } + if data, err := json.Marshal(memo); err == nil { + if f, name, err := c.CAS.createTemp("tool-"); err == nil { + if _, werr := f.Write(data); werr == nil && f.Close() == nil { + c.root.Rename(name, toolMemoPath) + } else { + f.Close() + } + c.root.Remove(name) + } + } + return []byte(sum) +} diff --git a/internal/ext/wasm/wasm.go b/internal/ext/wasm/wasm.go index aa74782fc4..74a9bad017 100644 --- a/internal/ext/wasm/wasm.go +++ b/internal/ext/wasm/wasm.go @@ -141,7 +141,7 @@ func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, exp // which is an implicit input of every action. Compiled artifacts are // materialized into an exec directory for wazero's compilation cache to // find; the authoritative copies live in the CAS. - compileAction := cache.NewAction("CompileModule"). + compileAction := store.NewAction("CompileModule"). AddInput("wasm", []byte(expected)). Digest() From 1b674d6d0d77e0f4a62cd78b7fc02f964fc51629 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 23:19:56 +0000 Subject: [PATCH 9/9] cache: harden the exec output-tree path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three issues in the action cache's tree materialization, found in adversarial review: 1. GetTree wrote output blobs to filepath.Join(dir, rel) with plain os calls, where rel is an output name read from the (non-self-validating) action cache entry. A tampered entry with a "../" name could escape the exec directory and write attacker-controlled CAS content anywhere the process could — contradicting the confinement the rest of the cache gets from os.Root. Reject any rel that is not filepath.IsLocal. 2. GetTree trusted an already-present file by size alone and never re-hashed it, and staged writes without fsync. A right-sized but torn or corrupt exec file (power loss, bitrot) was therefore trusted forever: wazero hard-fails deserializing it instead of recompiling, and the size match kept GetTree from ever repairing it from the CAS, bricking codegen until a manual cache wipe. Reuse an existing file only when it still hashes to the digest, and stage writes through an fsynced temp + rename. 3. The exec directory was shared across processes at exec/. wazero stages .tmp files in place while compiling, which a concurrent process's PutTree WalkDir could sweep into its action result. ExecDir now returns a fresh private directory per call, and the WASM runner removes it once wazero has loaded the module; the compiled bytes remain reproducible from the CAS. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MPgq3rwip76D554BktbqR4 --- internal/cache/action.go | 67 ++++++++++++++++++++++++++++----------- internal/cache/cache.go | 24 ++++++++------ internal/ext/wasm/wasm.go | 6 ++-- 3 files changed, 67 insertions(+), 30 deletions(-) diff --git a/internal/cache/action.go b/internal/cache/action.go index 3db20c15ab..16f4df9cbc 100644 --- a/internal/cache/action.go +++ b/internal/cache/action.go @@ -103,17 +103,30 @@ func (a *ActionCache) PutTree(action Digest, dir string) error { } // GetTree materializes a cached action's outputs as files under dir, or -// returns ErrNotFound on a miss. Files already present with the right size -// are left in place; missing ones are staged in their destination directory -// and renamed so concurrent processes never observe partial files. +// returns ErrNotFound on a miss. A file already present is reused only if its +// contents still hash to the expected digest; otherwise it is rewritten from +// the CAS. Writes are staged, fsynced, and renamed, so a crash cannot leave a +// right-sized but torn file that later reads would trust. func (a *ActionCache) GetTree(action Digest, dir string) error { result, err := a.Get(action) if err != nil { return err } for rel, d := range result.Outputs { + // Output names come from the action cache entry, which — unlike a CAS + // blob — is not self-validating, so a tampered entry could carry a + // name like "../../etc/x". Reject anything that isn't a relative path + // confined to dir; this is the confinement the os.Root gives the rest + // of the cache, which GetTree can't use because dir is a caller-owned + // path a tool must read by absolute name. + if !filepath.IsLocal(rel) { + return fmt.Errorf("cache: unsafe output path %q in action %s", rel, action) + } path := filepath.Join(dir, filepath.FromSlash(rel)) - if fi, err := os.Stat(path); err == nil && fi.Size() == d.SizeBytes { + // Trust an existing file only if it still hashes to the digest; + // size alone can mask in-place corruption that would make the + // consuming tool hard-fail with no way to repair (see wazero). + if existing, err := os.ReadFile(path); err == nil && DigestOf(existing) == d { continue } data, err := a.cas.Get(d) @@ -123,27 +136,43 @@ func (a *ActionCache) GetTree(action Digest, dir string) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf("cache: %w", err) } - f, err := os.CreateTemp(filepath.Dir(path), d.Hash[:8]+"-*") - if err != nil { - return fmt.Errorf("cache: %w", err) - } - if _, err := f.Write(data); err != nil { - f.Close() - os.Remove(f.Name()) - return fmt.Errorf("cache: %w", err) - } - if err := f.Close(); err != nil { - os.Remove(f.Name()) - return fmt.Errorf("cache: %w", err) - } - if err := os.Rename(f.Name(), path); err != nil { - os.Remove(f.Name()) + if err := writeFileAtomic(path, data); err != nil { return fmt.Errorf("cache: %w", err) } } return nil } +// writeFileAtomic writes data to path via a staged temp file in the same +// directory, fsynced before an atomic rename, so a reader never observes a +// partial or torn file even across a crash. +func writeFileAtomic(path string, data []byte) error { + f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*") + if err != nil { + return err + } + tmp := f.Name() + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return err + } + return nil +} + // Put records the result of an action. All outputs must already be in the // CAS; writes are staged and renamed so concurrent processes never observe a // partial entry. diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 2d7e89f3dc..f0528add0b 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -83,15 +83,21 @@ func (c *Cache) Close() error { return c.root.Close() } -// ExecDir returns a stable directory for materializing the output tree of -// the given action, for tools that need their outputs on disk (like wazero's -// compilation cache). It lives at exec/ under the cache root -// and, like everything else in the cache, is safe to delete at any time: the -// authoritative copy of its contents is the CAS. +// ExecDir creates a fresh, private scratch directory for materializing the +// output tree of the given action, for tools that need their outputs on disk +// (like wazero's compilation cache). Each call returns a new directory under +// exec/, so two concurrent processes never share one — otherwise a tool +// staging files there (wazero writes .tmp files in place) could be swept +// into the other's PutTree. The caller must remove it when done; its contents +// are always reproducible from the CAS, so losing it is harmless. func (c *Cache) ExecDir(action Digest) (string, error) { - rel := filepath.Join("exec", action.Hash) - if err := c.root.MkdirAll(rel, 0755); err != nil { - return "", fmt.Errorf("failed to create %s directory: %w", rel, err) + base := filepath.Join(c.root.Name(), "exec") + if err := os.MkdirAll(base, 0755); err != nil { + return "", fmt.Errorf("failed to create %s directory: %w", base, err) } - return filepath.Join(c.root.Name(), rel), nil + dir, err := os.MkdirTemp(base, action.Hash+"-") + if err != nil { + return "", fmt.Errorf("cache: %w", err) + } + return dir, nil } diff --git a/internal/ext/wasm/wasm.go b/internal/ext/wasm/wasm.go index 74a9bad017..e313ac29ff 100644 --- a/internal/ext/wasm/wasm.go +++ b/internal/ext/wasm/wasm.go @@ -139,8 +139,9 @@ func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, exp // Its only declared input is the module's checksum: the embedded wazero // version and the target platform are determined by the sqlc binary, // which is an implicit input of every action. Compiled artifacts are - // materialized into an exec directory for wazero's compilation cache to - // find; the authoritative copies live in the CAS. + // materialized into a private exec directory for wazero's compilation + // cache to find; the authoritative copies live in the CAS. Once wazero + // has loaded the module into memory the directory is no longer needed. compileAction := store.NewAction("CompileModule"). AddInput("wasm", []byte(expected)). Digest() @@ -149,6 +150,7 @@ func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, exp if err != nil { return nil, err } + defer os.RemoveAll(execDir) compiled := true if err := store.Actions.GetTree(compileAction, execDir); errors.Is(err, cache.ErrNotFound) { compiled = false