Skip to content

Commit b804f36

Browse files
committed
core: open the cached catalog as a file rather than copying it into memory
Now that analysis only reads the catalog, a restored one no longer has to be a private in-memory copy. The stored blob is opened where it lies: it is named after the hash of its contents, so it can never change under a reader, and SQLite is told as much with immutable=1. Any number of processes share the one file, with no locking and no copy — eight parallel runs against one blob check out, and a write to it is refused. CAS grows a Filename accessor for consumers that need the file rather than its bytes. A catalog being built is still an in-memory database, since building one is all writes; only the finished blob is a file. PostgreSQL analyze goes from 21ms to 19ms per warm run and stops copying 720KB per run to do it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011MnoUabwBWW9gaEn2Nj7eG
1 parent 40f953e commit b804f36

3 files changed

Lines changed: 51 additions & 35 deletions

File tree

internal/cache/cas.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ func (c *CAS) path(d Digest) string {
4141
return filepath.Join("cas", d.Hash[:2], d.Hash)
4242
}
4343

44+
// Filename returns the path of a stored blob, for a consumer that needs the
45+
// file rather than its bytes — SQLite, for one, opens a database by name. A
46+
// blob is named after the hash of its contents, so the file at this path never
47+
// changes and any number of processes may read it at once.
48+
//
49+
// It reports false when the blob is not stored.
50+
func (c *CAS) Filename(d Digest) (string, bool) {
51+
if !c.Contains(d) {
52+
return "", false
53+
}
54+
return filepath.Join(c.root.Name(), c.path(d)), true
55+
}
56+
4457
// createTemp creates a staging file under tmp/ in the cache root, returning
4558
// the open file and its root-relative name.
4659
func (c *CAS) createTemp(prefix string) (*os.File, string, error) {

internal/core/cache.go

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ const catalogOutput = "catalog.db"
1818
// implements on its connections.
1919
type serializer interface {
2020
Serialize() ([]byte, error)
21-
Deserialize([]byte) error
2221
}
2322

2423
// NewCached returns the catalog a dialect and a schema produce, restored from
@@ -27,8 +26,8 @@ type serializer interface {
2726
// Producing one means seeding the dialect — thousands of rows, since
2827
// PostgreSQL's functions and system catalogs alone run to five figures — and
2928
// then parsing the schema and applying its DDL. The result is the same every
30-
// time, so the finished database is serialized into the cache and
31-
// deserialized back on the next run, turning all of that work into one read.
29+
// time, so the finished database is stored in the cache and opened directly on
30+
// the next run, turning all of that work into opening a file.
3231
// It is a single action: the dialect and the schema together are what the
3332
// catalog is, so they are one key, and a hit means apply is never called.
3433
//
@@ -81,25 +80,23 @@ func openAction(dialect string, schema []string) (*cache.Cache, cache.Digest) {
8180
return store, action.Digest()
8281
}
8382

84-
// restore opens the catalog a previous run cached.
83+
// restore opens the catalog a previous run cached. The stored blob is opened
84+
// where it lies rather than copied: analysis only ever reads the catalog, so
85+
// every run that wants this one reads the same file.
8586
func restore(store *cache.Cache, action cache.Digest) (*Catalog, error) {
8687
result, err := store.Actions.Get(action)
8788
if err != nil {
8889
return nil, err
8990
}
90-
blob, err := store.CAS.Get(result.Outputs[catalogOutput])
91-
if err != nil {
92-
return nil, err
91+
path, ok := store.CAS.Filename(result.Outputs[catalogOutput])
92+
if !ok {
93+
return nil, cache.ErrNotFound
9394
}
9495

95-
db, err := openDB()
96+
db, err := openFile(path)
9697
if err != nil {
9798
return nil, err
9899
}
99-
if err := deserialize(db, blob); err != nil {
100-
db.Close()
101-
return nil, err
102-
}
103100

104101
stmts := newStmtCache(db)
105102
cat := &Catalog{db: db, stmts: stmts, q: catalogdb.New(stmts)}
@@ -130,7 +127,8 @@ func save(store *cache.Cache, action cache.Digest, cat *Catalog) error {
130127
})
131128
}
132129

133-
// serialize returns the bytes a database would be written to disk as.
130+
// serialize returns the bytes a database being built in memory would be
131+
// written to disk as, which is what gets stored.
134132
func serialize(db *sql.DB) ([]byte, error) {
135133
var blob []byte
136134
err := withRawConn(db, func(s serializer) error {
@@ -144,18 +142,6 @@ func serialize(db *sql.DB) ([]byte, error) {
144142
return blob, nil
145143
}
146144

147-
// deserialize loads a serialized database into an open one. The pool is
148-
// pinned to a single connection, so the connection this replaces the contents
149-
// of is the only one the catalog will ever use.
150-
func deserialize(db *sql.DB, blob []byte) error {
151-
if err := withRawConn(db, func(s serializer) error {
152-
return s.Deserialize(blob)
153-
}); err != nil {
154-
return fmt.Errorf("core: deserialize catalog: %w", err)
155-
}
156-
return nil
157-
}
158-
159145
func withRawConn(db *sql.DB, fn func(serializer) error) error {
160146
conn, err := db.Conn(context.Background())
161147
if err != nil {

internal/core/catalog.go

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,23 +64,40 @@ func New(opts ...Option) (*Catalog, error) {
6464
return c, nil
6565
}
6666

67-
// openDB opens the empty SQLite database a catalog is kept in.
68-
func openDB() (*sql.DB, error) {
69-
db, err := sql.Open("sqlite", ":memory:")
67+
// openFile opens a catalog held in a file, read only. The file is named after
68+
// the hash of its contents, so it can never change under a reader: SQLite is
69+
// told as much, which lets any number of processes share it with no locking
70+
// and no copy.
71+
func openFile(path string) (*sql.DB, error) {
72+
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&immutable=1")
7073
if err != nil {
71-
return nil, fmt.Errorf("core: open catalog: %w", err)
74+
return nil, fmt.Errorf("core: open catalog %s: %w", path, err)
7275
}
73-
// Every ":memory:" connection is its own empty database, so the pool has to
74-
// be pinned to a single connection that is never retired — otherwise a
75-
// second connection would see a catalog with no tables in it.
76+
pinPool(db)
77+
return db, nil
78+
}
79+
80+
// pinPool holds the catalog to a single connection that is never retired.
81+
// Every ":memory:" connection is its own empty database, so a second one would
82+
// see a catalog with no tables in it; a catalog read from a file has no such
83+
// constraint, but has no use for more connections either.
84+
func pinPool(db *sql.DB) {
7685
db.SetMaxOpenConns(1)
7786
db.SetMaxIdleConns(1)
7887
db.SetConnMaxIdleTime(0)
7988
db.SetConnMaxLifetime(0)
89+
}
90+
91+
// openDB opens the empty SQLite database a catalog is built in.
92+
func openDB() (*sql.DB, error) {
93+
db, err := sql.Open("sqlite", ":memory:")
94+
if err != nil {
95+
return nil, fmt.Errorf("core: open catalog: %w", err)
96+
}
97+
pinPool(db)
8098

81-
// The catalog is scratch state rebuilt on every run and never read back
82-
// from disk, so durability buys nothing and costs a journal write per
83-
// statement.
99+
// The catalog being built is scratch state that is never read back from
100+
// disk, so durability buys nothing and costs a journal write per statement.
84101
for _, pragma := range []string{
85102
"PRAGMA journal_mode = OFF",
86103
"PRAGMA synchronous = OFF",

0 commit comments

Comments
 (0)