Skip to content
20 changes: 18 additions & 2 deletions docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 42 additions & 27 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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 {
Expand All @@ -47,46 +44,54 @@ 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
if !c.db.Managed {
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)
Expand All @@ -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)
}

Expand Down
209 changes: 209 additions & 0 deletions internal/cache/action.go
Original file line number Diff line number Diff line change
@@ -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/<xx>/<hash> 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
}
Loading
Loading