Skip to content

Commit 5685216

Browse files
starbopsclaude
andcommitted
feat: implement PostgreSQL database foundation with complete schema design
Add comprehensive PostgreSQL database layer supporting the VoidRunner task execution platform: **Database Schema & Migrations:** - Users table with email uniqueness and password hash storage - Tasks table with JSONB metadata, priority, and execution constraints - Task executions table with performance metrics and status tracking - Automatic timestamp triggers and proper foreign key relationships - Up/down migrations with postgres extension compatibility **Repository Pattern:** - Interface-based repository design for dependency injection - User, Task, and TaskExecution repositories with full CRUD operations - Prepared statements and parameterized queries for security - Comprehensive error handling with proper context wrapping - JSONB metadata search with GIN indexing for performance **Connection Management:** - pgxpool configuration with optimal settings (25 max, 5 min connections) - Health checks, timeouts, and connection lifecycle management - Database initialization and migration runner in main.go **Testing & Validation:** - 71 test cases across unit, integration, and benchmark tests - Model validation for email, password, task content, and execution data - Repository tests with mock validation and error case coverage - Integration tests for real database operations (when enabled) **Performance Optimizations:** - Strategic indexing for common query patterns (<50ms target) - Connection pooling for concurrent request handling - GIN indexes for efficient JSONB metadata queries Closes #3 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7ef0acd commit 5685216

24 files changed

+4389
-4
lines changed

cmd/api/main.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/gin-gonic/gin"
1414
"github.com/voidrunnerhq/voidrunner/internal/api/routes"
1515
"github.com/voidrunnerhq/voidrunner/internal/config"
16+
"github.com/voidrunnerhq/voidrunner/internal/database"
1617
"github.com/voidrunnerhq/voidrunner/pkg/logger"
1718
)
1819

@@ -25,12 +26,46 @@ func main() {
2526

2627
log := logger.New(cfg.Logger.Level, cfg.Logger.Format)
2728

29+
// Initialize database connection
30+
dbConn, err := database.NewConnection(&cfg.Database, log.Logger)
31+
if err != nil {
32+
log.Error("failed to initialize database connection", "error", err)
33+
os.Exit(1)
34+
}
35+
defer dbConn.Close()
36+
37+
// Run database migrations
38+
migrateConfig := &database.MigrateConfig{
39+
DatabaseConfig: &cfg.Database,
40+
MigrationsPath: "file://migrations",
41+
Logger: log.Logger,
42+
}
43+
44+
if err := database.MigrateUp(migrateConfig); err != nil {
45+
log.Error("failed to run database migrations", "error", err)
46+
os.Exit(1)
47+
}
48+
49+
// Initialize repositories
50+
repos := database.NewRepositories(dbConn)
51+
52+
// Perform database health check
53+
healthCtx, healthCancel := context.WithTimeout(context.Background(), 5*time.Second)
54+
defer healthCancel()
55+
56+
if err := dbConn.HealthCheck(healthCtx); err != nil {
57+
log.Error("database health check failed", "error", err)
58+
os.Exit(1)
59+
}
60+
61+
log.Info("database initialized successfully")
62+
2863
if cfg.IsProduction() {
2964
gin.SetMode(gin.ReleaseMode)
3065
}
3166

3267
router := gin.New()
33-
routes.Setup(router, cfg, log)
68+
routes.Setup(router, cfg, log, repos)
3469

3570
srv := &http.Server{
3671
Addr: fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port),

go.mod

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,29 @@ require (
2121
github.com/go-playground/universal-translator v0.18.1 // indirect
2222
github.com/go-playground/validator/v10 v10.26.0 // indirect
2323
github.com/goccy/go-json v0.10.5 // indirect
24+
github.com/golang-migrate/migrate/v4 v4.18.3 // indirect
25+
github.com/hashicorp/errwrap v1.1.0 // indirect
26+
github.com/hashicorp/go-multierror v1.1.1 // indirect
27+
github.com/jackc/pgpassfile v1.0.0 // indirect
28+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
29+
github.com/jackc/pgx/v5 v5.7.5 // indirect
30+
github.com/jackc/puddle/v2 v2.2.2 // indirect
2431
github.com/json-iterator/go v1.1.12 // indirect
2532
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
2633
github.com/leodido/go-urn v1.4.0 // indirect
34+
github.com/lib/pq v1.10.9 // indirect
2735
github.com/mattn/go-isatty v0.0.20 // indirect
2836
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
2937
github.com/modern-go/reflect2 v1.0.2 // indirect
3038
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
3139
github.com/pmezard/go-difflib v1.0.0 // indirect
3240
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
3341
github.com/ugorji/go/codec v1.3.0 // indirect
42+
go.uber.org/atomic v1.7.0 // indirect
3443
golang.org/x/arch v0.18.0 // indirect
3544
golang.org/x/crypto v0.39.0 // indirect
3645
golang.org/x/net v0.41.0 // indirect
46+
golang.org/x/sync v0.15.0 // indirect
3747
golang.org/x/sys v0.33.0 // indirect
3848
golang.org/x/text v0.26.0 // indirect
3949
google.golang.org/protobuf v1.36.6 // indirect

go.sum

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,24 @@ github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc
2525
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
2626
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
2727
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
28+
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
29+
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
2830
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
2931
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
3032
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
33+
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
34+
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
35+
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
36+
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
37+
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
38+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
39+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
40+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
41+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
42+
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
43+
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
44+
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
45+
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
3146
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
3247
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
3348
github.com/joho/godotenv v1.6.0-pre.2 h1:SCkYm/XGeCcXItAv0Xofqsa4JPdDDkyNcG1Ush5cBLQ=
@@ -40,6 +55,8 @@ github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQe
4055
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
4156
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
4257
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
58+
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
59+
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
4360
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
4461
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
4562
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -65,12 +82,16 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
6582
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
6683
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
6784
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
85+
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
86+
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
6887
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
6988
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
7089
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
7190
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
7291
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
7392
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
93+
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
94+
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
7495
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
7596
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
7697
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=

internal/api/routes/routes.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import (
55
"github.com/voidrunnerhq/voidrunner/internal/api/handlers"
66
"github.com/voidrunnerhq/voidrunner/internal/api/middleware"
77
"github.com/voidrunnerhq/voidrunner/internal/config"
8+
"github.com/voidrunnerhq/voidrunner/internal/database"
89
"github.com/voidrunnerhq/voidrunner/pkg/logger"
910
)
1011

11-
func Setup(router *gin.Engine, cfg *config.Config, log *logger.Logger) {
12+
func Setup(router *gin.Engine, cfg *config.Config, log *logger.Logger, repos *database.Repositories) {
1213
setupMiddleware(router, cfg, log)
13-
setupRoutes(router)
14+
setupRoutes(router, repos)
1415
}
1516

1617
func setupMiddleware(router *gin.Engine, cfg *config.Config, log *logger.Logger) {
@@ -22,7 +23,7 @@ func setupMiddleware(router *gin.Engine, cfg *config.Config, log *logger.Logger)
2223
router.Use(middleware.ErrorHandler())
2324
}
2425

25-
func setupRoutes(router *gin.Engine) {
26+
func setupRoutes(router *gin.Engine, repos *database.Repositories) {
2627
healthHandler := handlers.NewHealthHandler()
2728

2829
router.GET("/health", healthHandler.Health)
@@ -35,5 +36,17 @@ func setupRoutes(router *gin.Engine) {
3536
"message": "pong",
3637
})
3738
})
39+
40+
// Future API routes will use repos here
41+
// userHandler := handlers.NewUserHandler(repos.Users)
42+
// taskHandler := handlers.NewTaskHandler(repos.Tasks)
43+
// executionHandler := handlers.NewTaskExecutionHandler(repos.TaskExecutions)
44+
45+
// v1.POST("/users", userHandler.Create)
46+
// v1.GET("/users/:id", userHandler.GetByID)
47+
// v1.POST("/tasks", taskHandler.Create)
48+
// v1.GET("/tasks/:id", taskHandler.GetByID)
49+
// v1.POST("/tasks/:id/executions", executionHandler.Create)
50+
// v1.GET("/executions/:id", executionHandler.GetByID)
3851
}
3952
}

internal/database/connection.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package database
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"time"
8+
9+
"github.com/jackc/pgx/v5/pgxpool"
10+
"github.com/voidrunnerhq/voidrunner/internal/config"
11+
)
12+
13+
// Connection represents a database connection pool
14+
type Connection struct {
15+
Pool *pgxpool.Pool
16+
logger *slog.Logger
17+
}
18+
19+
// NewConnection creates a new database connection pool
20+
func NewConnection(cfg *config.DatabaseConfig, logger *slog.Logger) (*Connection, error) {
21+
if cfg == nil {
22+
return nil, fmt.Errorf("database configuration is required")
23+
}
24+
25+
if logger == nil {
26+
logger = slog.Default()
27+
}
28+
29+
connStr := fmt.Sprintf(
30+
"postgres://%s:%s@%s:%s/%s?sslmode=%s",
31+
cfg.User,
32+
cfg.Password,
33+
cfg.Host,
34+
cfg.Port,
35+
cfg.Database,
36+
cfg.SSLMode,
37+
)
38+
39+
poolConfig, err := pgxpool.ParseConfig(connStr)
40+
if err != nil {
41+
return nil, fmt.Errorf("failed to parse database connection string: %w", err)
42+
}
43+
44+
// Configure connection pool settings for optimal performance
45+
poolConfig.MaxConns = 25 // Maximum number of connections
46+
poolConfig.MinConns = 5 // Minimum number of connections
47+
poolConfig.MaxConnLifetime = time.Hour * 1 // Maximum connection lifetime
48+
poolConfig.MaxConnIdleTime = time.Minute * 30 // Maximum connection idle time
49+
poolConfig.HealthCheckPeriod = time.Minute * 5 // Health check frequency
50+
51+
// Connection timeout settings
52+
poolConfig.ConnConfig.ConnectTimeout = time.Second * 10
53+
poolConfig.ConnConfig.RuntimeParams["statement_timeout"] = "30s"
54+
poolConfig.ConnConfig.RuntimeParams["idle_in_transaction_session_timeout"] = "60s"
55+
56+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
57+
defer cancel()
58+
59+
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
60+
if err != nil {
61+
return nil, fmt.Errorf("failed to create database pool: %w", err)
62+
}
63+
64+
// Test the connection
65+
if err := pool.Ping(ctx); err != nil {
66+
pool.Close()
67+
return nil, fmt.Errorf("failed to ping database: %w", err)
68+
}
69+
70+
logger.Info("database connection pool created successfully",
71+
"host", cfg.Host,
72+
"port", cfg.Port,
73+
"database", cfg.Database,
74+
"max_conns", poolConfig.MaxConns,
75+
"min_conns", poolConfig.MinConns,
76+
)
77+
78+
return &Connection{
79+
Pool: pool,
80+
logger: logger,
81+
}, nil
82+
}
83+
84+
// Close closes the database connection pool
85+
func (c *Connection) Close() {
86+
if c.Pool != nil {
87+
c.logger.Info("closing database connection pool")
88+
c.Pool.Close()
89+
}
90+
}
91+
92+
// Ping checks if the database connection is alive
93+
func (c *Connection) Ping(ctx context.Context) error {
94+
return c.Pool.Ping(ctx)
95+
}
96+
97+
// Stats returns connection pool statistics
98+
func (c *Connection) Stats() *pgxpool.Stat {
99+
return c.Pool.Stat()
100+
}
101+
102+
// LogStats logs connection pool statistics
103+
func (c *Connection) LogStats() {
104+
stats := c.Stats()
105+
c.logger.Info("database connection pool stats",
106+
"total_conns", stats.TotalConns(),
107+
"idle_conns", stats.IdleConns(),
108+
"acquired_conns", stats.AcquiredConns(),
109+
"constructing_conns", stats.ConstructingConns(),
110+
"acquire_count", stats.AcquireCount(),
111+
"acquire_duration", stats.AcquireDuration(),
112+
"acquired_conns_duration", stats.AcquiredConns(),
113+
"canceled_acquire_count", stats.CanceledAcquireCount(),
114+
"empty_acquire_count", stats.EmptyAcquireCount(),
115+
"max_conns", stats.MaxConns(),
116+
"new_conns_count", stats.NewConnsCount(),
117+
)
118+
}
119+
120+
// HealthCheck performs a comprehensive health check of the database connection
121+
func (c *Connection) HealthCheck(ctx context.Context) error {
122+
// Check if pool is available
123+
if c.Pool == nil {
124+
return fmt.Errorf("database pool is not initialized")
125+
}
126+
127+
// Ping the database
128+
if err := c.Pool.Ping(ctx); err != nil {
129+
return fmt.Errorf("database ping failed: %w", err)
130+
}
131+
132+
// Check pool statistics
133+
stats := c.Stats()
134+
if stats.TotalConns() == 0 {
135+
return fmt.Errorf("no database connections available")
136+
}
137+
138+
// Execute a simple query to ensure the database is responsive
139+
var result int
140+
err := c.Pool.QueryRow(ctx, "SELECT 1").Scan(&result)
141+
if err != nil {
142+
return fmt.Errorf("database query test failed: %w", err)
143+
}
144+
145+
if result != 1 {
146+
return fmt.Errorf("unexpected database query result: %d", result)
147+
}
148+
149+
return nil
150+
}

0 commit comments

Comments
 (0)