Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,4 @@ crash.*.log
# personal
docs/ideas.md
backup/
.kiro
23 changes: 8 additions & 15 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -1,35 +1,31 @@
package main

import (
"capuchin/internal/config"
"capuchin/internal/database"
"capuchin/internal/handlers"
"capuchin/internal/logger"
"capuchin/internal/routes"
"capuchin/internal/services"
"log"
"net/http"
"time"

"github.com/gin-gonic/gin"
)

func main() {
// Bootstrapping schema at startup to keep local/dev deployments self-contained.
if err := database.Connect(); err != nil {
log.Fatalf("Startup failed: %v", err)
}
database.InitSchema()
database.Connect(config.Config)
database.StartHealthMonitor()

// Periodic cleanup prevents the revoked-token table from growing forever.
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
if err := database.CleanupTokens(); err != nil {
log.Printf("Error cleaning up expired tokens: %v", err)
logger.Error("token.cleanup", "failed to delete expired tokens", err)
}
}
}()

// Handlers depend on interfaces so business logic can be swapped in tests.
authService := services.NewAuthService()
todoService := services.NewTodoService()

Expand All @@ -38,21 +34,18 @@ func main() {

r := gin.Default()

// Allow cross-origin requests so a separately hosted frontend can call this API.
// Restrict this in production to trusted origins.
// Restrict Access-Control-Allow-Origin to trusted origins in production.
r.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, PATCH")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
// Short-circuit preflight checks to avoid running downstream handlers.
c.AbortWithStatus(204)
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
})

// Keep route wiring centralized so auth boundaries are easy to audit.
routes.SetupRoutes(r, authHandler, todoHandler)

r.Run(":8080")
Expand Down
1 change: 0 additions & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ require (
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.11.2
golang.org/x/crypto v0.48.0
)
Expand Down
2 changes: 0 additions & 2 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
Expand Down
50 changes: 0 additions & 50 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
package config

import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"

"github.com/joho/godotenv"
)

type AppConfig struct {
Expand All @@ -24,8 +20,6 @@ var Config AppConfig
var JWTKey []byte

func init() {
loadEnvFile()

postgresPort, err := postgresPortFromEnv()
if err != nil {
log.Fatal(err)
Expand Down Expand Up @@ -53,50 +47,6 @@ func init() {
log.Println("Configuration loaded successfully.")
}

func loadEnvFile() {
cwd, err := os.Getwd()
if err != nil {
log.Fatalf("failed to determine current working directory: %v", err)
}

envPath, err := findEnvFile(cwd)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
log.Println("No .env file found in current or parent directories; using existing environment variables.")
return
}
log.Fatalf("failed to locate .env file: %v", err)
}

if err := godotenv.Load(envPath); err != nil {
log.Fatalf("failed to load .env file %q: %v", envPath, err)
}

log.Printf("Loaded environment variables from %s", envPath)
}

func findEnvFile(startDir string) (string, error) {
dir := startDir
for {
candidate := filepath.Join(dir, ".env")
info, err := os.Stat(candidate)
if err == nil && !info.IsDir() {
return candidate, nil
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", err
}

parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}

return "", os.ErrNotExist
}

func postgresPortFromEnv() (int, error) {
rawPort := os.Getenv("POSTGRES_PORT")
if rawPort == "" {
Expand Down
187 changes: 141 additions & 46 deletions backend/internal/database/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,78 +2,173 @@ package database

import (
"capuchin/internal/config"
"capuchin/internal/logger"
"context"
"database/sql"
"fmt"
"log"
"sync/atomic"
"time"

_ "github.com/lib/pq"
)

var DB *sql.DB
// db is the shared connection pool. Access via GetDB().
// Uses atomic.Pointer to avoid data races on connect/reconnect.
var db atomic.Pointer[sql.DB]

// dbHealthy is 1 when the last monitor ping succeeded, 0 otherwise.
var dbHealthy atomic.Int32

// degraded is a channel used to signal the monitor to switch to fast polling.
// Buffered so callers never block.
var degraded = make(chan struct{}, 1)

const (
dbMaxStartupAttempts = 5
dbStartupBaseDelay = 2 * time.Second
dbStartupMaxDelay = 30 * time.Second
connectRetryInterval = 5 * time.Second
healthyPingInterval = 30 * time.Second
degradedPingInterval = 5 * time.Second
pingTimeout = 2 * time.Second
)

func Connect() error {
connStr := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=disable",
config.Config.POSTGRES_HOST,
config.Config.POSTGRES_USER,
config.Config.POSTGRES_PASSWORD,
config.Config.POSTGRES_DB,
config.Config.POSTGRES_PORT,
// GetDB returns the active connection pool, or nil if not yet connected.
func GetDB() *sql.DB {
return db.Load()
}

// IsHealthy reports the cached DB health state set by StartHealthMonitor.
func IsHealthy() bool {
return dbHealthy.Load() == 1
}

// MarkDegraded signals the monitor to switch to fast polling immediately.
// Safe to call from any goroutine; never blocks.
func MarkDegraded() {
select {
case degraded <- struct{}{}:
default: // already signalled, drop
}
}

// Connect opens the connection pool once and launches a background goroutine
// that pings until ready, retrying on failure. Returns immediately.
func Connect(cfg config.AppConfig) {
connStr := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%d sslmode=disable",
cfg.POSTGRES_HOST,
cfg.POSTGRES_USER,
cfg.POSTGRES_PASSWORD,
cfg.POSTGRES_DB,
cfg.POSTGRES_PORT,
)

var err error
DB, err = sql.Open("postgres", connStr)
// sql.Open only validates the DSN — allocate the pool once outside the retry loop.
conn, err := sql.Open("postgres", connStr)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
logger.Error("database", "failed to open connection pool", err)
return
}

// Conservative pool settings avoid exhausting DB connections in small deployments.
DB.SetMaxOpenConns(25)
DB.SetMaxIdleConns(5)
// Recycling connections helps recover from stale network state over long uptimes.
DB.SetConnMaxLifetime(5 * time.Minute)
conn.SetMaxOpenConns(25)
conn.SetMaxIdleConns(5)
conn.SetConnMaxLifetime(5 * time.Minute)

if err = pingWithRetry(); err != nil {
DB.Close()
return fmt.Errorf("database unavailable after %d attempts: %w", dbMaxStartupAttempts, err)
}
go func() {
for {
ctx, cancel := context.WithTimeout(context.Background(), pingTimeout)
err := conn.PingContext(ctx)
cancel()

log.Println("Database connection established")
return nil
}
if err != nil {
logger.Error("database", "ping failed, retrying", err)
time.Sleep(connectRetryInterval)
continue
}

func pingWithRetry() error {
delay := dbStartupBaseDelay
for attempt := 1; attempt <= dbMaxStartupAttempts; attempt++ {
if err := DB.Ping(); err == nil {
return nil
} else {
log.Printf("Database ping failed (attempt %d/%d): %v", attempt, dbMaxStartupAttempts, err)
db.Store(conn)
dbHealthy.Store(1)
logger.Info("database", "connection established")
return
}
if attempt < dbMaxStartupAttempts {
log.Printf("Retrying in %s...", delay)
time.Sleep(delay)
delay *= 2
if delay > dbStartupMaxDelay {
delay = dbStartupMaxDelay
}()
}

// StartHealthMonitor runs a two-speed ping loop:
// - healthy: pings every 30s for observability
// - degraded: pings every 5s to detect recovery as soon as possible
//
// Switches to degraded mode when a ping fails or MarkDegraded() is called.
// Backs off to healthy interval once a ping succeeds again.
func StartHealthMonitor() {
go func() {
isDegraded := false
timer := time.NewTimer(healthyPingInterval)
defer timer.Stop()

for {
select {
case <-degraded:
// A query error was reported — switch to fast polling immediately
// without waiting for the current timer to fire.
if !isDegraded {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
isDegraded = true
timer.Reset(degradedPingInterval)
}

case <-timer.C:
pingMonitor(isDegraded)
if IsHealthy() {
isDegraded = false
timer.Reset(healthyPingInterval)
} else {
isDegraded = true
timer.Reset(degradedPingInterval)
}
}
}
}
return fmt.Errorf("database unreachable after %d attempts", dbMaxStartupAttempts)
}()
}

func InitSchema() {
log.Println("Database connection initialized. Assuming schema is already present.")
func pingMonitor(isDegraded bool) {
conn := GetDB()
if conn == nil {
dbHealthy.Store(0)
logger.Warn("database.monitor", "DB not yet connected", nil)
return
}

ctx, cancel := context.WithTimeout(context.Background(), pingTimeout)
err := conn.PingContext(ctx)
cancel()

if err != nil {
dbHealthy.Store(0)
logger.Warn("database.monitor", "DB unreachable", err)
return
}

if isDegraded {
// Only log recovery and stats when coming back from a degraded state.
stats := conn.Stats()
logger.Info("database.monitor", fmt.Sprintf(
"DB recovered — open=%d idle=%d waitCount=%d",
stats.OpenConnections, stats.Idle, stats.WaitCount,
))
}
dbHealthy.Store(1)
}

// CleanupTokens deletes expired blacklisted tokens.
func CleanupTokens() error {
// Expired tokens can be dropped because JWT expiration already invalidates them.
_, err := DB.Exec("DELETE FROM blacklisted_tokens WHERE expired_at < $1", time.Now())
conn := GetDB()
if conn == nil {
return fmt.Errorf("database: not connected")
}
_, err := conn.Exec("DELETE FROM blacklisted_tokens WHERE expired_at < NOW()")
return err
}
13 changes: 13 additions & 0 deletions backend/internal/database/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package database

import "database/sql"

// HandleQueryError signals the health monitor to switch to fast polling
// when a real connectivity error occurs, as opposed to expected errors
// like sql.ErrNoRows which don't indicate DB unavailability.
func HandleQueryError(err error) {
if err == nil || err == sql.ErrNoRows {
return
}
MarkDegraded()
}
Loading