@@ -38,41 +38,113 @@ func init() {
3838 }
3939}
4040
41−// Connect opens a SQLite database connection and runs migrations.
41+// connEntry holds a shared database connection and its reference count.
42+type connEntry struct {
43+ db *sql.DB
44+ refCount int
45+}
46+
47+var (
48+ pool = make(map[string]*connEntry)
49+ poolMu sync.Mutex
50+)
51+
52+// Connect opens a SQLite database connection for the given data
53+// directory and runs migrations. If a connection to the same database
54+// file already exists, the existing connection is returned with its
55+// reference count incremented. Callers must pair each Connect with a
56+// [Release] when they no longer need the connection.
4257func Connect(ctx context.Context, dataDir string) (*sql.DB, error) {
4358 if dataDir == "" {
4459 return nil, fmt.Errorf("data.dir is not set")
4560 }
61+
4662 dbPath := filepath.Join(dataDir, "crush.db")
4763
48− db, err := openDB(dbPath)
64+ // Resolve to an absolute path so that different relative paths to
65+ // the same file share a single connection.
66+ absPath, err := filepath.Abs(dbPath)
67+ if err != nil {
68+ absPath = dbPath
69+ }
70+
71+ poolMu.Lock()
72+ defer poolMu.Unlock()
73+
74+ if entry, ok := pool[absPath]; ok {
75+ entry.refCount++
76+ return entry.db, nil
77+ }
78+
79+ conn, err := openDB(dbPath)
4980 if err != nil {
5081 return nil, err
5182 }
5283
53− // Serialize all access through a single connection. SQLite serializes
54− // writes at the file level anyway, and allowing multiple pool
55− // connections to interleave writes/checkpoints (especially under
56− // concurrent sub-agents) has caused WAL/header desync resulting in
57− // SQLITE_NOTADB (26) on the next open.
58− db.SetMaxOpenConns(1)
84+ // Serialize all access through a single connection. SQLite
85+ // serializes writes at the file level anyway, and allowing multiple
86+ // pool connections to interleave writes/checkpoints (especially
87+ // under concurrent sub-agents) has caused WAL/header desync
88+ // resulting in SQLITE_NOTADB (26) on the next open.
89+ conn.SetMaxOpenConns(1)
5990
60− if err = db.PingContext(ctx); err != nil {
61− db.Close()
91+ if err = conn.PingContext(ctx); err != nil {
92+ conn.Close()
6293 return nil, fmt.Errorf("failed to connect to database: %w", err)
6394 }
6495
6596 if err := initGoose(); err != nil {
97+ conn.Close()
6698 slog.Error("Failed to initialize goose", "error", err)
6799 return nil, fmt.Errorf("failed to initialize goose: %w", err)
68100 }
69101
70− if err := goose.Up(db, "migrations"); err != nil {
102+ if err := goose.Up(conn, "migrations"); err != nil {
103+ conn.Close()
71104 slog.Error("Failed to apply migrations", "error", err)
72105 return nil, fmt.Errorf("failed to apply migrations: %w", err)
73106 }
74107
75− return db, nil
108+ pool[absPath] = &connEntry{db: conn, refCount: 1}
109+ return conn, nil
110+}
111+
112+// Release decrements the reference count for the database at the given
113+// data directory. When the count reaches zero the underlying connection
114+// is closed and removed from the pool.
115+func Release(dataDir string) error {
116+ dbPath := filepath.Join(dataDir, "crush.db")
117+ absPath, err := filepath.Abs(dbPath)
118+ if err != nil {
119+ absPath = dbPath
120+ }
121+
122+ poolMu.Lock()
123+ defer poolMu.Unlock()
124+
125+ entry, ok := pool[absPath]
126+ if !ok {
127+ return nil
128+ }
129+
130+ entry.refCount--
131+ if entry.refCount > 0 {
132+ return nil
133+ }
134+
135+ delete(pool, absPath)
136+ return entry.db.Close()
137+}
138+
139+// ResetPool closes all pooled connections and clears the pool. This is
140+// intended for use in tests to ensure a clean state between test cases.
141+func ResetPool() {
142+ poolMu.Lock()
143+ defer poolMu.Unlock()
144+ for path, entry := range pool {
145+ entry.db.Close()
146+ delete(pool, path)
147+ }
76148}
77149
78150func initGoose() error {