connect_test.go

  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}