1package sqlite
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "sync"
9
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/logging"
12)
13
14// LazyDB implements port.DatabaseProvider with lazy initialization.
15// The database connection is created on first access, deferring the
16// connection and migration overhead until actually needed.
17//
18// Callers may warm the provider with Warmup while other startup work (for
19// example engine initialization) runs concurrently. Warmup registers its
20// background task synchronously, so a Close that observes Warmup's return
21// always waits for the task to settle instead of racing goroutine
22// scheduling. Initialization admission is serialized with closure: once
23// Close begins, no new initialization can start and late callers receive a
24// closed-provider error, so no connection can open with nobody left to
25// close it. An initialization admitted before Close began runs to
26// completion (or aborts early via the owned init context) and its
27// connection is then released. Ownership contract: Warmup must return
28// before Close is called; concurrent Warmup and Close from different
29// goroutines without external sequencing is not supported.
30type LazyDB struct {
31 dbPath string
32 db *sql.DB
33 err error
34 once sync.Once
35 mu sync.RWMutex
36 // started is closed when the first initialization begins; done is
37 // closed when it completes. They let Close distinguish "never used"
38 // from "initialization in flight" without triggering initialization.
39 started chan struct{}
40 done chan struct{}
41 // warmTasks tracks one completion channel per Warmup call, registered
42 // synchronously under mu. Close waits for every registered task, so a
43 // Close that observes Warmup's return settles the background task even
44 // when the once-initializer has not been scheduled yet. Each channel is
45 // closed exactly once by its own goroutine after its DB call returns,
46 // which happens after the once-initializer completes, so observing all
47 // closures implies initialization has settled.
48 warmTasks []chan struct{}
49 // closed is set by Close under mu. The once-initializer checks it under
50 // the same mutex before opening anything, which serializes admission
51 // with closure: init admitted before Close runs to completion and is
52 // then released; init arriving after is skipped with ErrLazyDBClosed.
53 closed bool
54 // dbClosed records that Close released the connection, making repeat
55 // Close calls idempotent.
56 dbClosed bool
57 // initCtx carries the connection work; initCancel aborts an admitted
58 // in-flight initialization when Close begins so shutdown (and the
59 // engine-build error path) is not stuck behind a doomed migration.
60 // Canceling a settled provider is a no-op for later callers because
61 // closure already rejects them.
62 initCtx context.Context
63 initCancel context.CancelFunc
64}
65
66// Compile-time interface check.
67var _ port.DatabaseProvider = (*LazyDB)(nil)
68
69// ErrLazyDBClosed is returned to callers arriving after the provider was
70// closed, including initializations that lose the admission race.
71var ErrLazyDBClosed = errors.New("sqlite: database provider closed")
72
73// initGateForTest, when non-nil, runs inside the once-initializer after
74// admission is granted and before the connection is opened. Tests use it to
75// hold an admitted initialization in flight. It must remain nil in
76// production.
77var initGateForTest func()
78
79// NewLazyDB creates a new lazy database provider.
80// The actual connection is not established until DB() is called, or until a
81// Warmup task triggers initialization in the background.
82func NewLazyDB(dbPath string) *LazyDB {
83 initCtx, initCancel := context.WithCancel(context.Background())
84 return &LazyDB{
85 dbPath: dbPath,
86 started: make(chan struct{}),
87 done: make(chan struct{}),
88 initCtx: initCtx,
89 initCancel: initCancel,
90 }
91}
92
93// DB returns the database connection, initializing it if necessary.
94// This method is thread-safe and will only initialize once. Calls arriving
95// after Close receive ErrLazyDBClosed without starting initialization.
96func (l *LazyDB) DB(ctx context.Context) (*sql.DB, error) {
97 l.once.Do(func() {
98 close(l.started)
99 defer close(l.done)
100 l.mu.Lock()
101 admitted := !l.closed
102 l.mu.Unlock()
103 if !admitted {
104 l.mu.Lock()
105 l.err = ErrLazyDBClosed
106 l.mu.Unlock()
107 logging.FromContext(ctx).Debug().Msg("lazy database initialization skipped: provider closed")
108 return
109 }
110 if initGateForTest != nil {
111 initGateForTest()
112 }
113 log := logging.FromContext(ctx)
114 log.Debug().Str("path", l.dbPath).Msg("lazy database initialization starting")
115
116 // The owned init context carries the connection work so Close can
117 // abort a doomed initialization; caller values are preserved for
118 // logging above. Shutdown cancellation can no longer poison the
119 // cache because closure rejects later callers.
120 db, err := NewConnection(l.initCtx, l.dbPath)
121
122 // Acquire lock before assigning to ensure IsInitialized() sees
123 // a consistent state when reading l.db under RLock.
124 l.mu.Lock()
125 l.db = db
126 l.err = err
127 l.mu.Unlock()
128
129 if err != nil {
130 log.Error().Err(err).Msg("lazy database initialization failed")
131 } else {
132 log.Debug().Msg("lazy database initialized successfully")
133 }
134 })
135
136 l.mu.RLock()
137 db, err, closed := l.db, l.err, l.closed
138 l.mu.RUnlock()
139
140 if err != nil {
141 if errors.Is(err, ErrLazyDBClosed) {
142 return nil, ErrLazyDBClosed
143 }
144 return nil, fmt.Errorf("database initialization failed: %w", err)
145 }
146 if closed {
147 return nil, ErrLazyDBClosed
148 }
149 return db, nil
150}
151
152// Warmup triggers database initialization in an owned background task and
153// returns immediately. Initialization errors are cached and reported to
154// later DB callers; pass a detached context (for example
155// context.WithoutCancel) so shutdown cancellation cannot poison the cached
156// result. The task is registered synchronously, so Close called after
157// Warmup returns settles it (see the ownership contract on LazyDB).
158func (l *LazyDB) Warmup(ctx context.Context) {
159 task := make(chan struct{})
160 l.mu.Lock()
161 l.warmTasks = append(l.warmTasks, task)
162 l.mu.Unlock()
163 go func() {
164 defer close(task)
165 _, _ = l.DB(ctx)
166 }()
167}
168
169// Close seals the provider against new initialization, aborts admitted
170// in-flight connection work via the owned init context, settles every
171// registered Warmup task and awaited initialization, then releases the
172// connection. No connection can open after Close begins: late initializers
173// are skipped with ErrLazyDBClosed and admitted ones are awaited and
174// closed. Close never triggers initialization itself, and repeat calls are
175// idempotent.
176func (l *LazyDB) Close() error {
177 // Abort doomed connection work first so shutdown and the engine-build
178 // error path are not stuck behind a hung open or migration. Safe to
179 // call repeatedly and on settled providers.
180 l.initCancel()
181
182 l.mu.Lock()
183 l.closed = true
184 tasks := make([]chan struct{}, len(l.warmTasks))
185 copy(tasks, l.warmTasks)
186 l.mu.Unlock()
187 for _, task := range tasks {
188 <-task
189 }
190
191 select {
192 case <-l.started:
193 <-l.done
194 default:
195 return nil
196 }
197
198 l.mu.Lock()
199 defer l.mu.Unlock()
200
201 if l.dbClosed || l.db == nil {
202 return nil
203 }
204 err := l.db.Close()
205 l.dbClosed = true
206 return err
207}
208
209// IsInitialized returns true if the database has been initialized.
210func (l *LazyDB) IsInitialized() bool {
211 l.mu.RLock()
212 defer l.mu.RUnlock()
213 return l.db != nil
214}
215
216// Path returns the database path.
217func (l *LazyDB) Path() string {
218 return l.dbPath
219}