diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 837dd13980..291c578c02 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -20,10 +20,26 @@ 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 (query analysis + 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 (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. + ## SQLCDEBUG The `SQLCDEBUG` variable controls debugging variables within the runtime. It is diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 674f283db9..357d617235 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -3,18 +3,14 @@ package analyzer import ( "context" "encoding/json" - "fmt" - "hash/fnv" + "errors" "log/slog" - "os" - "path/filepath" "google.golang.org/protobuf/proto" "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" ) @@ -24,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 { @@ -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,39 +54,44 @@ func (c *CachedAnalyzer) analyze(ctx context.Context, n ast.Node, q string, sche return nil, true, nil } - dir, err := cache.AnalysisDir() - 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 } } - // 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 configuration, the + // schema migrations, and the query itself. (The sqlc binary is an + // implicit input of every action.) + action := store.NewAction("QueryAnalysis"). + 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,16 +102,26 @@ 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 } 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 new file mode 100644 index 0000000000..16f4df9cbc --- /dev/null +++ b/internal/cache/action.go @@ -0,0 +1,209 @@ +package cache + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "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. +// +// 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 *os.Root + cas *CAS +} + +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("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 := a.root.ReadFile(path) + if err != nil { + 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 { + a.root.Remove(path) + return nil, ErrNotFound + } + for _, d := range result.Outputs { + if !a.cas.Contains(d) { + a.root.Remove(path) + return nil, ErrNotFound + } + } + 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. 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)) + // 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) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("cache: %w", err) + } + 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. +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 := a.root.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("cache: %w", err) + } + f, name, err := a.cas.createTemp(action.Hash[:8] + "-") + if err != nil { + return fmt.Errorf("cache: %w", err) + } + defer a.root.Remove(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 := 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 a6978034a7..f0528add0b 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -1,3 +1,21 @@ +// 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 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//. +// +// 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 ( @@ -6,10 +24,17 @@ 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. 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 *os.Root + 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,26 +47,57 @@ 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 dir, nil + return OpenAt(root) } -func AnalysisDir() (string, error) { - cacheRoot, err := Dir() +// OpenAt returns the cache rooted at the given directory, creating it if +// necessary. +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 "", err + return nil, fmt.Errorf("cache: %w", err) + } + cas, err := newCAS(root) + if err != nil { + root.Close() + return nil, err + } + return &Cache{ + root: root, + CAS: cas, + Actions: newActionCache(root, cas), + }, nil +} + +// Close releases the cache's handle on its root directory. +func (c *Cache) Close() error { + return c.root.Close() +} + +// 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) { + 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) } - dir := filepath.Join(cacheRoot, "query_analysis") - if err := os.MkdirAll(dir, 0755); err != nil && !os.IsExist(err) { - return "", fmt.Errorf("failed to create %s directory: %w", dir, err) + dir, err := os.MkdirTemp(base, action.Hash+"-") + if err != nil { + return "", fmt.Errorf("cache: %w", err) } return dir, nil } diff --git a/internal/cache/cas.go b/internal/cache/cas.go new file mode 100644 index 0000000000..c2210d50dc --- /dev/null +++ b/internal/cache/cas.go @@ -0,0 +1,125 @@ +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. +var ErrNotFound = errors.New("cache: not found") + +// 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. +// +// 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 *os.Root +} + +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}, nil +} + +// path returns a blob's path relative to the cache root. +func (c *CAS) path(d Digest) string { + 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 +// 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 := c.root.Stat(path); err == nil && fi.Size() == d.SizeBytes { + return d, nil + } + if err := c.root.MkdirAll(filepath.Dir(path), 0755); err != nil { + return Digest{}, fmt.Errorf("cache: %w", err) + } + f, name, err := c.createTemp(d.Hash[:8] + "-") + if err != nil { + return Digest{}, fmt.Errorf("cache: %w", err) + } + defer c.root.Remove(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 := c.root.Rename(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 redo the work that produced it. +func (c *CAS) Get(d Digest) ([]byte, error) { + if !d.valid() { + return nil, ErrNotFound + } + path := c.path(d) + data, err := c.root.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("cache: %w", err) + } + if DigestOf(data).Hash != d.Hash { + c.root.Remove(path) + return nil, ErrNotFound + } + return data, nil +} + +// 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 := c.root.Stat(c.path(d)) + 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 new file mode 100644 index 0000000000..4909d466ed --- /dev/null +++ b/internal/cache/digest.go @@ -0,0 +1,100 @@ +package cache + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "hash" +) + +// 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 { + // 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("sha256:%s/%d", d.Hash, d.SizeBytes) +} + +func (d Digest) valid() bool { + if len(d.Hash) != 64 { + return false + } + _, err := hex.DecodeString(d.Hash) + return err == nil +} + +// DigestOf returns the Digest of a blob. +func DigestOf(data []byte) Digest { + sum := sha256.Sum256(data) + return Digest{ + Hash: hex.EncodeToString(sum[:]), + 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{ + 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 +// 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 hash.Hash +} + +// NewAction starts building an action key for the given mnemonic, e.g. +// "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. 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", c.toolDigest()) + 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 { + return Digest{ + Hash: hex.EncodeToString(a.hasher.Sum(nil)), + 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/cache/tool.go b/internal/cache/tool.go new file mode 100644 index 0000000000..51c8c42c2a --- /dev/null +++ b/internal/cache/tool.go @@ -0,0 +1,95 @@ +package cache + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "os" + "sync" + + "github.com/sqlc-dev/sqlc/internal/info" +) + +// 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) + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return []byte(info.Version) + } + 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 7653676493..e313ac29ff 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,13 @@ 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 } + defer store.Close() 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 +113,52 @@ 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 +func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, expected string) (*runtimeAndCode, error) { + // 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 + wmod, actual, err = r.fetch(ctx, r.URL) + if err != nil { + return nil, err + } + if expected != actual { + return nil, fmt.Errorf("invalid checksum: expected %s, got %s", expected, actual) + } + if _, err := store.CAS.Put(wmod); err != nil { + return nil, fmt.Errorf("cache wasm: %w", err) + } + } else if err != nil { + return nil, err } - wmod, actual, err := r.fetch(ctx, uri) + // 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 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() + + execDir, err := store.ExecDir(compileAction) if err != nil { return nil, err } - - if expected != actual { - return nil, fmt.Errorf("invalid checksum: expected %s, got %s", expected, actual) - } - - if staterr != nil { - err := os.Mkdir(pluginDir, 0755) - if err != nil && !os.IsExist(err) { - return nil, fmt.Errorf("mkdirall: %w", err) - } - if err := os.WriteFile(pluginPath, wmod, 0444); err != nil { - return nil, fmt.Errorf("cache wasm: %w", err) - } + defer os.RemoveAll(execDir) + 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(filepath.Join(cache, "wazero")) + wazeroCache, err := wazero.NewCompilationCacheWithDir(execDir) if err != nil { return nil, fmt.Errorf("wazero.NewCompilationCacheWithDir: %w", err) } @@ -155,12 +171,19 @@ func (r *Runner) loadAndCompileWASM(ctx context.Context, cache string, expected } // 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 }