v0.33.1 9577fee

lazy_db.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close