Skip to content

Commit 06d3255

Browse files
committed
cache: memoize the tool digest keyed by executable metadata
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPgq3rwip76D554BktbqR4
1 parent 6b9e17e commit 06d3255

4 files changed

Lines changed: 77 additions & 13 deletions

File tree

internal/analyzer/analyzer.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ func (c *CachedAnalyzer) analyze(ctx context.Context, n ast.Node, q string, sche
7474
// Analyzing a query is an action whose inputs are the configuration, the
7575
// schema migrations, and the query itself. (The sqlc binary is an
7676
// implicit input of every action.)
77-
action := cache.NewAction("QueryAnalysis").
77+
action := store.NewAction("QueryAnalysis").
7878
AddInput("config", c.configBytes)
7979
for _, m := range schema {
8080
action.AddInput("schema", []byte(m))

internal/cache/digest.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,12 @@ type Action struct {
6767
// "QueryAnalysis". The sha256 of the sqlc binary itself is always the first
6868
// input: the tool that executes an action determines its outputs just as
6969
// much as the declared inputs do, so a rebuilt sqlc never reuses stale
70-
// entries.
71-
func NewAction(mnemonic string) *Action {
70+
// entries. The binary's digest is memoized in the cache — see toolDigest —
71+
// which is why actions are created through a Cache.
72+
func (c *Cache) NewAction(mnemonic string) *Action {
7273
a := &Action{hasher: sha256.New()}
7374
a.write([]byte(mnemonic))
74-
a.AddInput("tool", toolDigest())
75+
a.AddInput("tool", c.toolDigest())
7576
return a
7677
}
7778

internal/cache/tool.go

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,68 @@ package cache
22

33
import (
44
"crypto/sha256"
5+
"encoding/hex"
6+
"encoding/json"
57
"io"
68
"os"
79
"sync"
810

911
"github.com/sqlc-dev/sqlc/internal/info"
1012
)
1113

12-
// toolDigest returns the sha256 of the running sqlc binary, hashed once per
13-
// process. The binary is an input to every action — a rebuilt sqlc may
14-
// analyze queries or embed a different wazero than the one that produced a
15-
// cache entry, even when the version string is unchanged (dev builds). If
16-
// the executable can't be read, fall back to the version string.
17-
var toolDigest = sync.OnceValue(func() []byte {
14+
// The sha256 of the sqlc binary is an input to every action — a rebuilt sqlc
15+
// may analyze queries or embed a different wazero than the one that produced
16+
// a cache entry, even when the version string is unchanged (dev builds).
17+
//
18+
// Hashing a ~100MB executable costs tens of milliseconds, too much to pay on
19+
// every short-lived sqlc process, so the digest is memoized on disk keyed by
20+
// the executable's path, size, and mtime — the same trick Bazel's file
21+
// digest cache uses. A warm run costs one stat and a tiny read; only a
22+
// rebuilt (or moved) binary is re-hashed.
23+
var tool struct {
24+
sync.Mutex
25+
digest []byte
26+
}
27+
28+
type toolMemo struct {
29+
Path string `json:"path"`
30+
SizeBytes int64 `json:"size_bytes"`
31+
MtimeNS int64 `json:"mtime_ns"`
32+
SHA256 string `json:"sha256"`
33+
}
34+
35+
const toolMemoPath = "tool"
36+
37+
func (c *Cache) toolDigest() []byte {
38+
tool.Lock()
39+
defer tool.Unlock()
40+
if tool.digest == nil {
41+
tool.digest = c.computeToolDigest()
42+
}
43+
return tool.digest
44+
}
45+
46+
func (c *Cache) computeToolDigest() []byte {
1847
path, err := os.Executable()
1948
if err != nil {
2049
return []byte(info.Version)
2150
}
51+
fi, err := os.Stat(path)
52+
if err != nil {
53+
return []byte(info.Version)
54+
}
55+
56+
var memo toolMemo
57+
if data, err := c.root.ReadFile(toolMemoPath); err == nil {
58+
if err := json.Unmarshal(data, &memo); err == nil &&
59+
memo.Path == path &&
60+
memo.SizeBytes == fi.Size() &&
61+
memo.MtimeNS == fi.ModTime().UnixNano() &&
62+
memo.SHA256 != "" {
63+
return []byte(memo.SHA256)
64+
}
65+
}
66+
2267
f, err := os.Open(path)
2368
if err != nil {
2469
return []byte(info.Version)
@@ -28,5 +73,23 @@ var toolDigest = sync.OnceValue(func() []byte {
2873
if _, err := io.Copy(h, f); err != nil {
2974
return []byte(info.Version)
3075
}
31-
return h.Sum(nil)
32-
})
76+
sum := hex.EncodeToString(h.Sum(nil))
77+
78+
memo = toolMemo{
79+
Path: path,
80+
SizeBytes: fi.Size(),
81+
MtimeNS: fi.ModTime().UnixNano(),
82+
SHA256: sum,
83+
}
84+
if data, err := json.Marshal(memo); err == nil {
85+
if f, name, err := c.CAS.createTemp("tool-"); err == nil {
86+
if _, werr := f.Write(data); werr == nil && f.Close() == nil {
87+
c.root.Rename(name, toolMemoPath)
88+
} else {
89+
f.Close()
90+
}
91+
c.root.Remove(name)
92+
}
93+
}
94+
return []byte(sum)
95+
}

internal/ext/wasm/wasm.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ func (r *Runner) loadAndCompileWASM(ctx context.Context, store *cache.Cache, exp
141141
// which is an implicit input of every action. Compiled artifacts are
142142
// materialized into an exec directory for wazero's compilation cache to
143143
// find; the authoritative copies live in the CAS.
144-
compileAction := cache.NewAction("CompileModule").
144+
compileAction := store.NewAction("CompileModule").
145145
AddInput("wasm", []byte(expected)).
146146
Digest()
147147

0 commit comments

Comments
 (0)