@@ -38,41 +38,113 @@ func init() {
41+// connEntry holds a shared database connection and its reference count.
42+type connEntry struct {
48+ pool = make(map[string]*connEntry)
41−// Connect opens a SQLite database connection and runs migrations.
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.
42func Connect(ctx context.Context, dataDir string) (*sql.DB, error) {
57func Connect(ctx context.Context, dataDir string) (*sql.DB, error) {
43 if dataDir == "" {
58 if dataDir == "" {
44 return nil, fmt.Errorf("data.dir is not set")
59 return nil, fmt.Errorf("data.dir is not set")
46 dbPath := filepath.Join(dataDir, "crush.db")
62 dbPath := filepath.Join(dataDir, "crush.db")
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)
72+ defer poolMu.Unlock()
74+ if entry, ok := pool[absPath]; ok {
48− db, err := openDB(dbPath)
79+ conn, err := openDB(dbPath)
49 if err != nil {
80 if err != nil {
50 return nil, err
81 return nil, err
53− // Serialize all access through a single connection. SQLite serializes
84+ // Serialize all access through a single connection. SQLite
54− // writes at the file level anyway, and allowing multiple pool
85+ // serializes writes at the file level anyway, and allowing multiple
55− // connections to interleave writes/checkpoints (especially under
86+ // pool connections to interleave writes/checkpoints (especially
56− // concurrent sub-agents) has caused WAL/header desync resulting in
87+ // under concurrent sub-agents) has caused WAL/header desync
57− // SQLITE_NOTADB (26) on the next open.
88+ // resulting in SQLITE_NOTADB (26) on the next open.
58− db.SetMaxOpenConns(1)
89+ conn.SetMaxOpenConns(1)
60− if err = db.PingContext(ctx); err != nil {
91+ if err = conn.PingContext(ctx); err != nil {
61− db.Close()
92+ conn.Close()
62 return nil, fmt.Errorf("failed to connect to database: %w", err)
93 return nil, fmt.Errorf("failed to connect to database: %w", err)
65 if err := initGoose(); err != nil {
96 if err := initGoose(); err != nil {
66 slog.Error("Failed to initialize goose", "error", err)
98 slog.Error("Failed to initialize goose", "error", err)
67 return nil, fmt.Errorf("failed to initialize goose: %w", err)
99 return nil, fmt.Errorf("failed to initialize goose: %w", err)
70− if err := goose.Up(db, "migrations"); err != nil {
102+ if err := goose.Up(conn, "migrations"); err != nil {
71 slog.Error("Failed to apply migrations", "error", err)
104 slog.Error("Failed to apply migrations", "error", err)
72 return nil, fmt.Errorf("failed to apply migrations: %w", err)
105 return nil, fmt.Errorf("failed to apply migrations: %w", err)
108+ pool[absPath] = &connEntry{db: conn, refCount: 1}
75− return db, nil
109+ return conn, nil
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)
123+ defer poolMu.Unlock()
125+ entry, ok := pool[absPath]
131+ if entry.refCount > 0 {
135+ delete(pool, absPath)
136+ return entry.db.Close()
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.
143+ defer poolMu.Unlock()
144+ for path, entry := range pool {
78func initGoose() error {
150func initGoose() error {