1package db
2
3import (
4 "context"
5 "errors"
6 "path/filepath"
7 "testing"
8
9 "github.com/charmbracelet/crush/internal/lock"
10 "github.com/stretchr/testify/require"
11)
12
13func TestConnect_SharesConnectionForSameDataDir(t *testing.T) {
14 t.Cleanup(ResetPool)
15
16 dataDir := t.TempDir()
17
18 conn1, err := Connect(context.Background(), dataDir)
19 require.NoError(t, err)
20
21 conn2, err := Connect(context.Background(), dataDir)
22 require.NoError(t, err)
23
24 require.Same(t, conn1, conn2, "should return the same *sql.DB for the same data dir")
25
26 // Releasing once should not close the connection.
27 require.NoError(t, Release(dataDir))
28 require.NoError(t, conn1.PingContext(context.Background()), "connection should still be usable after partial release")
29
30 // Releasing again should close it.
31 require.NoError(t, Release(dataDir))
32 require.Error(t, conn1.PingContext(context.Background()), "connection should be closed after final release")
33}
34
35func TestConnect_SeparateConnectionsForDifferentDataDirs(t *testing.T) {
36 t.Cleanup(ResetPool)
37
38 dir1 := t.TempDir()
39 dir2 := t.TempDir()
40
41 conn1, err := Connect(context.Background(), dir1)
42 require.NoError(t, err)
43
44 conn2, err := Connect(context.Background(), dir2)
45 require.NoError(t, err)
46
47 require.NotSame(t, conn1, conn2, "different data dirs should get different connections")
48
49 require.NoError(t, Release(dir1))
50 require.NoError(t, Release(dir2))
51}
52
53func TestRelease_NoopForUnknownDataDir(t *testing.T) {
54 t.Cleanup(ResetPool)
55
56 require.NoError(t, Release("/nonexistent/path"), "releasing unknown data dir should not error")
57}
58
59// TestConnect_FailsWhenDataDirLocked simulates a second crush process by
60// taking the data-dir lock directly via the OS primitive on a separate
61// file descriptor and then asserting that Connect surfaces a clean
62// ErrDataDirLocked instead of opening the database under contention.
63func TestConnect_FailsWhenDataDirLocked(t *testing.T) {
64 t.Cleanup(ResetPool)
65
66 dataDir := t.TempDir()
67 lockPath := filepath.Join(dataDir, dataDirLockFile)
68
69 release, err := lock.TryFile(lockPath)
70 require.NoError(t, err, "expected to take the data-dir lock for the first time")
71 t.Cleanup(release)
72
73 _, err = Connect(context.Background(), dataDir, WithDataDirLock(true))
74 require.Error(t, err, "Connect must refuse to open a locked data dir")
75 require.ErrorIs(t, err, ErrDataDirLocked)
76}
77
78// TestConnect_SucceedsAfterContenderReleases ensures the lock is purely
79// advisory and that a clean release lets the next Connect proceed.
80func TestConnect_SucceedsAfterContenderReleases(t *testing.T) {
81 t.Cleanup(ResetPool)
82
83 dataDir := t.TempDir()
84 lockPath := filepath.Join(dataDir, dataDirLockFile)
85
86 release, err := lock.TryFile(lockPath)
87 require.NoError(t, err)
88
89 _, err = Connect(context.Background(), dataDir, WithDataDirLock(true))
90 require.ErrorIs(t, err, ErrDataDirLocked)
91
92 release()
93
94 conn, err := Connect(context.Background(), dataDir, WithDataDirLock(true))
95 require.NoError(t, err, "Connect should succeed once the contender releases the lock")
96 require.NoError(t, conn.PingContext(context.Background()))
97 require.NoError(t, Release(dataDir))
98}
99
100// TestConnect_LockReleasedOnFinalRelease confirms that closing the last
101// reference to a pool entry also drops the OS lock, so subsequent
102// processes can take the data dir.
103func TestConnect_LockReleasedOnFinalRelease(t *testing.T) {
104 t.Cleanup(ResetPool)
105
106 dataDir := t.TempDir()
107 lockPath := filepath.Join(dataDir, dataDirLockFile)
108
109 conn, err := Connect(context.Background(), dataDir, WithDataDirLock(true))
110 require.NoError(t, err)
111 require.NoError(t, conn.PingContext(context.Background()))
112
113 // Holding the in-process entry must keep the OS lock held so a
114 // "second process" (simulated by a fresh lock.TryFile call) is
115 // rejected.
116 _, lockErr := lock.TryFile(lockPath)
117 require.Error(t, lockErr)
118 require.True(t, errors.Is(lockErr, lock.ErrContended), "expected contended lock while pool entry is live")
119
120 require.NoError(t, Release(dataDir))
121
122 // After the final release the lock is free again.
123 release, err := lock.TryFile(lockPath)
124 require.NoError(t, err, "expected lock to be released after final Release")
125 release()
126}
127
128// TestConnect_SharedPoolDoesNotReacquireLock makes sure that subsequent
129// in-process Connect calls reuse the existing OS lock through refcount,
130// not by re-acquiring it. The simplest observable signal of correctness
131// is that the second Connect does not error and the lock is still held
132// after a single Release.
133func TestConnect_SharedPoolDoesNotReacquireLock(t *testing.T) {
134 t.Cleanup(ResetPool)
135
136 dataDir := t.TempDir()
137 lockPath := filepath.Join(dataDir, dataDirLockFile)
138
139 _, err := Connect(context.Background(), dataDir, WithDataDirLock(true))
140 require.NoError(t, err)
141
142 _, err = Connect(context.Background(), dataDir, WithDataDirLock(true))
143 require.NoError(t, err)
144
145 // Drop one reference; lock must still be held.
146 require.NoError(t, Release(dataDir))
147 _, lockErr := lock.TryFile(lockPath)
148 require.ErrorIs(t, lockErr, lock.ErrContended)
149
150 require.NoError(t, Release(dataDir))
151}
152
153// TestConnect_SkipLockEnvBypassesAcquisition exercises the escape
154// hatch used by users on filesystems where flock is unreliable.
155func TestConnect_SkipLockEnvBypassesAcquisition(t *testing.T) {
156 t.Cleanup(ResetPool)
157
158 dataDir := t.TempDir()
159 lockPath := filepath.Join(dataDir, dataDirLockFile)
160
161 release, err := lock.TryFile(lockPath)
162 require.NoError(t, err)
163 t.Cleanup(release)
164
165 t.Setenv("CRUSH_SKIP_DATADIR_LOCK", "1")
166
167 conn, err := Connect(context.Background(), dataDir, WithDataDirLock(true))
168 require.NoError(t, err, "skip-lock env should bypass contention")
169 require.NoError(t, conn.PingContext(context.Background()))
170 require.NoError(t, Release(dataDir))
171}
172
173// TestConnect_DefaultIgnoresContendedLock confirms that without
174// WithDataDirLock(true) the lock file is irrelevant: a contender can
175// hold lock.TryFile and Connect still succeeds. This pins the
176// local-mode default to its pre-lock behavior.
177func TestConnect_DefaultIgnoresContendedLock(t *testing.T) {
178 t.Cleanup(ResetPool)
179
180 dataDir := t.TempDir()
181 lockPath := filepath.Join(dataDir, dataDirLockFile)
182
183 release, err := lock.TryFile(lockPath)
184 require.NoError(t, err, "expected to take the data-dir lock for the first time")
185 t.Cleanup(release)
186
187 conn, err := Connect(context.Background(), dataDir)
188 require.NoError(t, err, "default Connect must not take the lock and must succeed under contention")
189 require.NoError(t, conn.PingContext(context.Background()))
190 require.NoError(t, Release(dataDir))
191}
192
193// TestConnect_ServerPathFailsWhenDataDirLocked is the server's
194// workspace-bootstrap analogue of TestConnect_FailsWhenDataDirLocked:
195// passing WithDataDirLock(true) must surface ErrDataDirLocked when a
196// contender already holds the lock.
197func TestConnect_ServerPathFailsWhenDataDirLocked(t *testing.T) {
198 t.Cleanup(ResetPool)
199
200 dataDir := t.TempDir()
201 lockPath := filepath.Join(dataDir, dataDirLockFile)
202
203 release, err := lock.TryFile(lockPath)
204 require.NoError(t, err, "expected to take the data-dir lock for the first time")
205 t.Cleanup(release)
206
207 _, err = Connect(context.Background(), dataDir, WithDataDirLock(true))
208 require.Error(t, err, "server-path Connect must refuse to open a locked data dir")
209 require.ErrorIs(t, err, ErrDataDirLocked)
210}