Skip to content

Commit bcb2670

Browse files
committed
fix(db): Harden embedded and ephemeral database test lifecycle
Bound database probes and operations to prevent hung test runs, and clean up started servers when lock release fails. Harden stale-database cleanup against malformed or future-dated names and parse PostgreSQL settings correctly. Extend the e2e timeout for slower macOS runners and expanded coverage.
1 parent 4d0ae07 commit bcb2670

8 files changed

Lines changed: 88 additions & 18 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ jobs:
3535
matrix:
3636
os: [ubuntu-latest, macos-latest]
3737
go-version: ["1.26"]
38-
timeout-minutes: 30
38+
# Ginkgo e2e (10m) + embedded pool tests (10m) + helper unit tests (5m) +
39+
# a full coverage re-run of the e2e suite, on a slower macOS runner.
40+
timeout-minutes: 45
3941

4042
# Exercises the default embedded-postgres path on both platforms. The
4143
# external-DB path is covered by e2e-external, which needs a Linux-only

db/embedded_lifecycle.go

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net"
99
"os"
1010
"path/filepath"
11+
"strconv"
1112
"sync"
1213
"time"
1314

@@ -20,6 +21,7 @@ import (
2021
const (
2122
embeddedStartupLockTimeout = 5 * time.Minute
2223
embeddedStartupRetryDelay = 100 * time.Millisecond
24+
embeddedProbeTimeout = 200 * time.Millisecond
2325
)
2426

2527
var embeddedStartupMu sync.Mutex
@@ -123,9 +125,14 @@ func (r *embeddedRuntime) withStartupLock(
123125

124126
dsn, stop, startErr := fn()
125127
unlockErr := lock.Unlock()
126-
if startErr != nil || unlockErr != nil {
128+
if startErr != nil {
127129
return "", nil, errors.Join(startErr, unlockErr)
128130
}
131+
if unlockErr != nil {
132+
// The server did start, so its stop closer must not be dropped on the
133+
// floor: shut it back down rather than leaking an unmanaged cluster.
134+
return "", nil, errors.Join(unlockErr, stop())
135+
}
129136
return dsn, stop, nil
130137
}
131138

@@ -178,7 +185,9 @@ func (r *embeddedRuntime) resolveLockedConfig() error {
178185
}
179186

180187
func (r *embeddedRuntime) reuseRunningServer(dsn string) (bool, error) {
181-
if !portIsListening(r.port) {
188+
ctx, cancel := context.WithTimeout(context.Background(), embeddedProbeTimeout)
189+
defer cancel()
190+
if !portIsListening(ctx, r.port) {
182191
return false, nil
183192
}
184193
if err := r.validateServer(dsn); err != nil {
@@ -245,7 +254,6 @@ func validateEmbeddedDataDirectory(ctx context.Context, conn *pgx.Conn, expected
245254

246255
func validateFastTestingServer(ctx context.Context, conn *pgx.Conn) error {
247256
var fsync, synchronousCommit, fullPageWrites string
248-
var maxConnections int
249257
if err := conn.QueryRow(ctx, "SHOW fsync").Scan(&fsync); err != nil {
250258
return fmt.Errorf("read fsync: %w", err)
251259
}
@@ -255,12 +263,29 @@ func validateFastTestingServer(ctx context.Context, conn *pgx.Conn) error {
255263
if err := conn.QueryRow(ctx, "SHOW full_page_writes").Scan(&fullPageWrites); err != nil {
256264
return fmt.Errorf("read full_page_writes: %w", err)
257265
}
258-
if err := conn.QueryRow(ctx, "SHOW max_connections").Scan(&maxConnections); err != nil {
259-
return fmt.Errorf("read max_connections: %w", err)
266+
maxConnections, err := showInt(ctx, conn, "max_connections")
267+
if err != nil {
268+
return err
260269
}
261270
return validateFastTestingSettings(fsync, synchronousCommit, fullPageWrites, maxConnections)
262271
}
263272

273+
// showInt reads a numeric server setting. SHOW always reports text (OID 25)
274+
// regardless of the underlying GUC type, so the value has to be parsed rather
275+
// than scanned straight into an int. setting is always a package-level literal;
276+
// SHOW takes no bind parameters.
277+
func showInt(ctx context.Context, conn *pgx.Conn, setting string) (int, error) {
278+
var raw string
279+
if err := conn.QueryRow(ctx, "SHOW "+setting).Scan(&raw); err != nil {
280+
return 0, fmt.Errorf("read %s: %w", setting, err)
281+
}
282+
value, err := strconv.Atoi(raw)
283+
if err != nil {
284+
return 0, fmt.Errorf("parse %s %q: %w", setting, raw, err)
285+
}
286+
return value, nil
287+
}
288+
264289
func (r *embeddedRuntime) serverConfig() embeddedpostgres.Config {
265290
config := embeddedpostgres.DefaultConfig().
266291
Port(r.port).
@@ -299,8 +324,9 @@ func (r *embeddedRuntime) dsn() string {
299324
)
300325
}
301326

302-
func portIsListening(port uint32) bool {
303-
conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), 200*time.Millisecond)
327+
func portIsListening(ctx context.Context, port uint32) bool {
328+
var dialer net.Dialer
329+
conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("localhost:%d", port))
304330
if err != nil {
305331
return false
306332
}

db/embedded_lifecycle_integration_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,12 @@ var _ = Describe("embedded PostgreSQL lifecycle", Label("integration"), func() {
4747
defer conn.Close(context.Background()) //nolint:errcheck
4848

4949
var dataDirectory, fsync, synchronousCommit, fullPageWrites string
50-
var maxConnections int
5150
Expect(conn.QueryRow(ctx, "SHOW data_directory").Scan(&dataDirectory)).To(Succeed())
5251
Expect(conn.QueryRow(ctx, "SHOW fsync").Scan(&fsync)).To(Succeed())
5352
Expect(conn.QueryRow(ctx, "SHOW synchronous_commit").Scan(&synchronousCommit)).To(Succeed())
5453
Expect(conn.QueryRow(ctx, "SHOW full_page_writes").Scan(&fullPageWrites)).To(Succeed())
55-
Expect(conn.QueryRow(ctx, "SHOW max_connections").Scan(&maxConnections)).To(Succeed())
54+
maxConnections, err := showInt(ctx, conn, "max_connections")
55+
Expect(err).NotTo(HaveOccurred())
5656

5757
expectedDataDirectory, err := filepath.EvalSymlinks(filepath.Join(config.DataDir, "data"))
5858
Expect(err).NotTo(HaveOccurred())

dbtest/dbtest.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ const (
3535
EnvCreate = "COMMONS_DB_CREATE"
3636
)
3737

38+
// connectTimeout bounds the blocking Postgres calls made while resolving a test
39+
// database, so an unreachable server fails the test instead of hanging it.
40+
const connectTimeout = 30 * time.Second
41+
3842
// Options configures how a test database is resolved.
3943
type Options struct {
4044
// Name seeds the database name. Required.
@@ -84,7 +88,9 @@ func (d *DB) SQL() *sql.DB {
8488
d.fail(fmt.Errorf("open %s: %w", redact(d.dsn), err))
8589
return nil
8690
}
87-
if err := handle.Ping(); err != nil {
91+
ctx, cancel := context.WithTimeout(context.Background(), connectTimeout)
92+
defer cancel()
93+
if err := handle.PingContext(ctx); err != nil {
8894
_ = handle.Close()
8995
d.fail(fmt.Errorf("ping %s: %w", redact(d.dsn), err))
9096
return nil
@@ -164,7 +170,7 @@ func Open(opts Options) (*DB, func() error, error) {
164170
if err != nil {
165171
return nil, nil, err
166172
}
167-
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
173+
ctx, cancel := context.WithTimeout(context.Background(), connectTimeout)
168174
defer cancel()
169175
dsn, drop, err := acquirePooledScratch(ctx, adminURL, opts.Name, db.unique, time.Now())
170176
if err != nil {

dbtest/ephemeral.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,13 @@ func managedDatabaseName(prefix string, created time.Time, unique, name string)
4949
if name == "" {
5050
return base
5151
}
52-
description := sanitize(name)
52+
// The identity is what keeps concurrent runs apart, so when it fills the
53+
// identifier there is no room left for a human-readable description.
5354
room := maxIdentifier - len(base) - 1
55+
if room <= 0 {
56+
return base
57+
}
58+
description := sanitize(name)
5459
if len(description) > room {
5560
description = description[:room]
5661
}

dbtest/ephemeral_pool.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"sync"
99
"time"
1010

11+
"github.com/flanksource/commons/logger"
1112
"github.com/jackc/pgerrcode"
1213
"github.com/lib/pq"
1314
)
@@ -217,16 +218,22 @@ func (p *ephemeralPool) cleanupStale(
217218
return err
218219
}
219220
for _, name := range names {
221+
// An unparseable or future-dated entry is skipped rather than fatal.
222+
// cleanupStale runs under the pool-wide advisory lock, so aborting here
223+
// would wedge every checkout for this prefix pair until someone cleaned
224+
// up by hand - and a clock step-back is enough to produce one.
220225
created, err := managedDatabaseCreatedWithPrefixes(
221226
name,
222227
p.config.PoolPrefix,
223228
p.config.TestPrefix,
224229
)
225230
if err != nil {
226-
return err
231+
logger.Warnf("dbtest: skipping unrecognised managed database %q: %v", name, err)
232+
continue
227233
}
228234
if created.After(now) {
229-
return fmt.Errorf("managed database name %q has a future timestamp", name)
235+
logger.Warnf("dbtest: skipping managed database %q with a future timestamp %s", name, created)
236+
continue
230237
}
231238
if now.Sub(created) <= p.config.MaxAge {
232239
continue

dbtest/ephemeral_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dbtest
22

33
import (
4+
"fmt"
45
"path/filepath"
56
"strings"
67
"time"
@@ -58,6 +59,23 @@ var _ = Describe("ephemeral database configuration", func() {
5859
Expect(name).To(ContainSubstring(testUnique))
5960
})
6061

62+
It("drops the description entirely when the identity fills the identifier", func() {
63+
// prefix + unix seconds + "_" + unique consumes all 63 characters, so
64+
// there is no room left for even a one-character description.
65+
identity := fmt.Sprintf("%s%d_", testDatabasePrefix, created.Unix())
66+
unique := strings.Repeat("u", maxIdentifier-len(identity))
67+
68+
name := managedDatabaseName(testDatabasePrefix, created, unique, testName)
69+
70+
Expect(len(name)).To(Equal(maxIdentifier))
71+
Expect(name).To(Equal(identity + unique))
72+
Expect(name).NotTo(ContainSubstring("migration_runner"))
73+
74+
parsed, err := managedDatabaseCreated(name)
75+
Expect(err).NotTo(HaveOccurred())
76+
Expect(parsed).To(Equal(created))
77+
})
78+
6179
DescribeTable("rejects names outside the managed namespace",
6280
func(name string) {
6381
_, err := managedDatabaseCreated(name)

dbtest/scratch.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dbtest
22

33
import (
4+
"context"
45
"crypto/rand"
56
"database/sql"
67
"encoding/hex"
@@ -36,11 +37,14 @@ func createScratch(adminURL, name, unique string) (string, func() error, error)
3637
}
3738
defer admin.Close() //nolint:errcheck
3839

40+
ctx, cancel := context.WithTimeout(context.Background(), connectTimeout)
41+
defer cancel()
42+
3943
quoted := pq.QuoteIdentifier(dbName)
40-
if _, err := admin.Exec(`DROP DATABASE IF EXISTS ` + quoted + ` WITH (FORCE)`); err != nil {
44+
if _, err := admin.ExecContext(ctx, `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`); err != nil {
4145
return "", nil, fmt.Errorf("drop stale database %s: %w", dbName, err)
4246
}
43-
if _, err := admin.Exec(`CREATE DATABASE ` + quoted); err != nil {
47+
if _, err := admin.ExecContext(ctx, `CREATE DATABASE `+quoted); err != nil {
4448
return "", nil, fmt.Errorf("create database %s: %w", dbName, err)
4549
}
4650

@@ -55,7 +59,9 @@ func createScratch(adminURL, name, unique string) (string, func() error, error)
5559
return fmt.Errorf("open %s to drop %s: %w", redact(adminURL), dbName, err)
5660
}
5761
defer cleanup.Close() //nolint:errcheck
58-
if _, err := cleanup.Exec(`DROP DATABASE IF EXISTS ` + quoted + ` WITH (FORCE)`); err != nil {
62+
dropCtx, dropCancel := context.WithTimeout(context.Background(), connectTimeout)
63+
defer dropCancel()
64+
if _, err := cleanup.ExecContext(dropCtx, `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`); err != nil {
5965
return fmt.Errorf("drop database %s: %w", dbName, err)
6066
}
6167
return nil

0 commit comments

Comments
 (0)