From 3ee060def4ea81272035e455e6abc40228ff5616 Mon Sep 17 00:00:00 2001 From: Sung Date: Sun, 26 Oct 2025 13:33:21 -0700 Subject: [PATCH] Auto vacuum and manage connection --- pkg/server/app/books.go | 1 + pkg/server/app/notes.go | 1 + pkg/server/app/users.go | 2 ++ pkg/server/cmd/start.go | 8 +++++ pkg/server/controllers/books.go | 5 +++ pkg/server/database/database.go | 63 +++++++++++++++++++++++++++++++++ 6 files changed, 80 insertions(+) diff --git a/pkg/server/app/books.go b/pkg/server/app/books.go index a476a0c4..ae7b355f 100644 --- a/pkg/server/app/books.go +++ b/pkg/server/app/books.go @@ -37,6 +37,7 @@ func (a *App) CreateBook(user database.User, name string) (database.Book, error) uuid, err := helpers.GenUUID() if err != nil { + tx.Rollback() return database.Book{}, err } diff --git a/pkg/server/app/notes.go b/pkg/server/app/notes.go index e9f6f60b..7f25123a 100644 --- a/pkg/server/app/notes.go +++ b/pkg/server/app/notes.go @@ -55,6 +55,7 @@ func (a *App) CreateNote(user database.User, bookUUID, content string, addedOn * uuid, err := helpers.GenUUID() if err != nil { + tx.Rollback() return database.Note{}, err } diff --git a/pkg/server/app/users.go b/pkg/server/app/users.go index 6e9535e7..eb53c5a4 100644 --- a/pkg/server/app/users.go +++ b/pkg/server/app/users.go @@ -66,9 +66,11 @@ func (a *App) CreateUser(email, password string, passwordConfirmation string) (d var count int64 if err := tx.Model(&database.User{}).Where("email = ?", email).Count(&count).Error; err != nil { + tx.Rollback() return database.User{}, pkgErrors.Wrap(err, "counting user") } if count > 0 { + tx.Rollback() return database.User{}, ErrDuplicateEmail } diff --git a/pkg/server/cmd/start.go b/pkg/server/cmd/start.go index cbbc60ed..dee5913c 100644 --- a/pkg/server/cmd/start.go +++ b/pkg/server/cmd/start.go @@ -22,10 +22,12 @@ import ( "fmt" "net/http" "os" + "time" "github.com/dnote/dnote/pkg/server/buildinfo" "github.com/dnote/dnote/pkg/server/config" "github.com/dnote/dnote/pkg/server/controllers" + "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/log" "github.com/pkg/errors" ) @@ -67,6 +69,12 @@ func startCmd(args []string) { } }() + // Start WAL checkpointing to prevent WAL file from growing unbounded. + database.StartWALCheckpointing(app.DB, 5*time.Minute) + + // Start periodic VACUUM to reclaim space and defragment database. + database.StartPeriodicVacuum(app.DB, 24*time.Hour) + ctl := controllers.New(&app) rc := controllers.RouteConfig{ WebRoutes: controllers.NewWebRoutes(&app, ctl), diff --git a/pkg/server/controllers/books.go b/pkg/server/controllers/books.go index 1b4f3810..c721b594 100644 --- a/pkg/server/controllers/books.go +++ b/pkg/server/controllers/books.go @@ -203,11 +203,13 @@ func (b *Books) update(r *http.Request) (database.Book, error) { var book database.Book if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil { + tx.Rollback() return database.Book{}, pkgErrors.Wrap(err, "finding book") } var params updateBookPayload if err := parseRequestData(r, ¶ms); err != nil { + tx.Rollback() return database.Book{}, pkgErrors.Wrap(err, "decoding payload") } @@ -253,11 +255,13 @@ func (b *Books) del(r *http.Request) (database.Book, error) { var book database.Book if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil { + tx.Rollback() return database.Book{}, pkgErrors.Wrap(err, "finding a book") } var notes []database.Note if err := tx.Where("book_uuid = ? AND NOT deleted", uuid).Order("usn ASC").Find(¬es).Error; err != nil { + tx.Rollback() return database.Book{}, pkgErrors.Wrap(err, "finding notes for the book") } @@ -270,6 +274,7 @@ func (b *Books) del(r *http.Request) (database.Book, error) { book, err := b.app.DeleteBook(tx, *user, book) if err != nil { + tx.Rollback() return database.Book{}, pkgErrors.Wrap(err, "deleting the book") } diff --git a/pkg/server/database/database.go b/pkg/server/database/database.go index f73d45af..2c5241b4 100644 --- a/pkg/server/database/database.go +++ b/pkg/server/database/database.go @@ -21,6 +21,7 @@ package database import ( "os" "path/filepath" + "time" "github.com/pkg/errors" "gorm.io/driver/sqlite" @@ -58,5 +59,67 @@ func Open(dbPath string) *gorm.DB { panic(errors.Wrap(err, "opening database conection")) } + // Get underlying *sql.DB to configure connection pool + sqlDB, err := db.DB() + if err != nil { + panic(errors.Wrap(err, "getting underlying database connection")) + } + + // Configure connection pool for SQLite with WAL mode + sqlDB.SetMaxOpenConns(25) + sqlDB.SetMaxIdleConns(5) + sqlDB.SetConnMaxLifetime(0) // Doesn't expire. + + // Apply performance PRAGMAs + pragmas := []string{ + "PRAGMA journal_mode=WAL", // Enable WAL mode for better concurrency + "PRAGMA synchronous=NORMAL", // Balance between safety and speed + "PRAGMA cache_size=-64000", // 64MB cache (negative = KB) + "PRAGMA busy_timeout=5000", // Wait up to 5s for locks + "PRAGMA foreign_keys=ON", // Enforce foreign key constraints + "PRAGMA temp_store=MEMORY", // Store temp tables in memory + } + + for _, pragma := range pragmas { + if err := db.Exec(pragma).Error; err != nil { + panic(errors.Wrapf(err, "executing pragma: %s", pragma)) + } + } + return db } + +// StartWALCheckpointing starts a background goroutine that periodically +// checkpoints the WAL file to prevent it from growing unbounded +func StartWALCheckpointing(db *gorm.DB, interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + // TRUNCATE mode removes the WAL file after checkpointing + if err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)").Error; err != nil { + // Log error but don't panic - this is a background maintenance task + // TODO: Use proper logging once available + _ = err + } + } + }() +} + +// StartPeriodicVacuum runs full VACUUM on a schedule to reclaim space and defragment. +// WARNING: VACUUM acquires an exclusive lock and blocks all database operations briefly. +func StartPeriodicVacuum(db *gorm.DB, interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + if err := db.Exec("VACUUM").Error; err != nil { + // Log error but don't panic - this is a background maintenance task + // TODO: Use proper logging once available + _ = err + } + } + }() +}