All checks were successful
check / check (push) Successful in 2m50s
GORM's default logger printed the fully interpolated SQL to standard
output on every statement that returned an error, including a plain
record-not-found. On /webhook/{uuid} and on the login form the
interpolated parameter is client-chosen and unbounded, so an
unauthenticated client sized the operator's log, one line per request,
at no level the operator could turn down.
Every gorm.Open in the service now installs internal/gormlog, a
gormlogger.Interface over the service's *slog.Logger. Its lines take
the level the operator set and the handler internal/logger selected; a
record-not-found is not logged as an error, since it is the expected
outcome on both of those paths and each handler already records its
own miss at DEBUG without the SQL; slow statements are kept at WARN
above the same 200ms threshold GORM used; and every value it emits is
spent through an encoded-byte budget.
Trace orders its cases exactly as GORM's own Trace orders them --
error-that-is-not-a-miss, then slow, then routine -- so a statement
that both missed and ran slow is still reported as slow. Ordering the
drop first would have made this adapter strictly less observant than
the IgnoreRecordNotFoundError option it was chosen over, on the two
lookups the issue is about, and a miss is the statement most likely to
be slow.
That budget is internal/middleware's truncateLogField, moved to a new
internal/logfield package now that a second writer needs it. The move
is unchanged logic. MaxAccessLogLineBytes bounds a GORM line too, and
internal/gormlog asserts each line against the constant directly.
The third gorm.Open, in the archive writer, was not named in the issue
and had the same default. All three sites are pinned independently:
internal/handlers covers the main and per-webhook databases,
internal/delivery covers the archive writer, whose type is unexported.
Reverting any one of the three to a bare &gorm.Config{} fails the
suite.
The flood test's per-line and volume assertions were vacuous, because
the replaced default logger wrote only to a buffer while everything
else went to the captured stdout. It now tees to stdout as GORM's real
default does, so a reverted call site lands in the same capture and
those assertions measure the whole writer set.
README: the ceiling now covers GORM, and the writers it does not cover
are re-derived by measuring rather than by reading. fx's console
logger and the Go runtime write to standard error. net/http's nil
ErrorLog is not a separate writer at all -- slog.SetDefault redirects
the log package's default logger into internal/logger's handler, so
those lines arrive on standard output at INFO. A handler panic reaches
that same path because chi's Recoverer crashes before writing, which
is filed as #187 and is also the widest line the service can write, at
2,772 bytes against the stated 2,560.
286 lines
5.8 KiB
Go
286 lines
5.8 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"go.uber.org/fx"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
)
|
|
|
|
// WebhookDBManagerParams holds the fx dependencies for
|
|
// WebhookDBManager.
|
|
type WebhookDBManagerParams struct {
|
|
fx.In
|
|
|
|
Config *config.Config
|
|
Logger *logger.Logger
|
|
}
|
|
|
|
// errInvalidCachedDBType indicates a type assertion failure
|
|
// when retrieving a cached database connection.
|
|
var errInvalidCachedDBType = errors.New(
|
|
"invalid cached database type",
|
|
)
|
|
|
|
// WebhookDBManager manages per-webhook SQLite database files
|
|
// for event storage. Each webhook gets its own dedicated
|
|
// database containing Events, Deliveries, and DeliveryResults.
|
|
// Database connections are opened lazily and cached.
|
|
type WebhookDBManager struct {
|
|
dataDir string
|
|
dbs sync.Map // map[webhookID]*gorm.DB
|
|
log *slog.Logger
|
|
}
|
|
|
|
// NewWebhookDBManager creates a new WebhookDBManager and
|
|
// registers lifecycle hooks.
|
|
func NewWebhookDBManager(
|
|
lc fx.Lifecycle,
|
|
params WebhookDBManagerParams,
|
|
) (*WebhookDBManager, error) {
|
|
m := &WebhookDBManager{
|
|
dataDir: params.Config.DataDir,
|
|
log: params.Logger.Get(),
|
|
}
|
|
|
|
// Create data directory if it doesn't exist
|
|
err := os.MkdirAll(m.dataDir, dataDirPerm)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"creating data directory %s: %w",
|
|
m.dataDir,
|
|
err,
|
|
)
|
|
}
|
|
|
|
lc.Append(fx.Hook{
|
|
OnStop: func(_ context.Context) error {
|
|
return m.CloseAll()
|
|
},
|
|
})
|
|
|
|
m.log.Info(
|
|
"webhook database manager initialized",
|
|
"data_dir", m.dataDir,
|
|
)
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// GetDB returns the database connection for a webhook,
|
|
// creating the database file lazily if it doesn't exist.
|
|
func (m *WebhookDBManager) GetDB(
|
|
webhookID string,
|
|
) (*gorm.DB, error) {
|
|
// Fast path: already open
|
|
if val, ok := m.dbs.Load(webhookID); ok {
|
|
cachedDB, castOK := val.(*gorm.DB)
|
|
if !castOK {
|
|
return nil, fmt.Errorf(
|
|
"%w for webhook %s",
|
|
errInvalidCachedDBType,
|
|
webhookID,
|
|
)
|
|
}
|
|
|
|
return cachedDB, nil
|
|
}
|
|
|
|
// Slow path: open/create the database
|
|
db, err := m.openDB(webhookID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Store it; if another goroutine beat us, close ours
|
|
actual, loaded := m.dbs.LoadOrStore(webhookID, db)
|
|
if loaded {
|
|
// Another goroutine created it first; close our duplicate
|
|
sqlDB, closeErr := db.DB()
|
|
if closeErr == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
|
|
existingDB, castOK := actual.(*gorm.DB)
|
|
if !castOK {
|
|
return nil, fmt.Errorf(
|
|
"%w for webhook %s",
|
|
errInvalidCachedDBType,
|
|
webhookID,
|
|
)
|
|
}
|
|
|
|
return existingDB, nil
|
|
}
|
|
|
|
return db, nil
|
|
}
|
|
|
|
// CreateDB explicitly creates a new per-webhook database file
|
|
// and runs migrations.
|
|
func (m *WebhookDBManager) CreateDB(
|
|
webhookID string,
|
|
) error {
|
|
_, err := m.GetDB(webhookID)
|
|
|
|
return err
|
|
}
|
|
|
|
// DBExists checks if a per-webhook database file exists on
|
|
// disk.
|
|
func (m *WebhookDBManager) DBExists(
|
|
webhookID string,
|
|
) bool {
|
|
_, err := os.Stat(m.dbPath(webhookID))
|
|
|
|
return err == nil
|
|
}
|
|
|
|
// DeleteDB closes the connection and deletes the database file
|
|
// for a webhook. The file is permanently removed.
|
|
func (m *WebhookDBManager) DeleteDB(
|
|
webhookID string,
|
|
) error {
|
|
// Close and remove from cache
|
|
if val, ok := m.dbs.LoadAndDelete(webhookID); ok {
|
|
if gormDB, castOK := val.(*gorm.DB); castOK {
|
|
sqlDB, err := gormDB.DB()
|
|
if err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete the main DB file and WAL/SHM files
|
|
path := m.dbPath(webhookID)
|
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
|
err := os.Remove(path + suffix)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf(
|
|
"deleting webhook database file %s%s: %w",
|
|
path, suffix, err,
|
|
)
|
|
}
|
|
}
|
|
|
|
m.log.Info(
|
|
"deleted per-webhook database",
|
|
"webhook_id", webhookID,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// CloseAll closes all open per-webhook database connections.
|
|
// Called during application shutdown.
|
|
func (m *WebhookDBManager) CloseAll() error {
|
|
var lastErr error
|
|
|
|
m.dbs.Range(func(key, value any) bool {
|
|
if gormDB, castOK := value.(*gorm.DB); castOK {
|
|
sqlDB, err := gormDB.DB()
|
|
if err == nil {
|
|
closeErr := sqlDB.Close()
|
|
if closeErr != nil {
|
|
lastErr = closeErr
|
|
m.log.Error(
|
|
"failed to close webhook database",
|
|
"webhook_id", key,
|
|
"error", closeErr,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
m.dbs.Delete(key)
|
|
|
|
return true
|
|
})
|
|
|
|
return lastErr
|
|
}
|
|
|
|
// DBPath returns the filesystem path for a webhook's database
|
|
// file.
|
|
func (m *WebhookDBManager) DBPath(
|
|
webhookID string,
|
|
) string {
|
|
return m.dbPath(webhookID)
|
|
}
|
|
|
|
func (m *WebhookDBManager) dbPath(
|
|
webhookID string,
|
|
) string {
|
|
return filepath.Join(
|
|
m.dataDir,
|
|
fmt.Sprintf("events-%s.db", webhookID),
|
|
)
|
|
}
|
|
|
|
// openDB opens (or creates) a per-webhook SQLite database and
|
|
// runs migrations.
|
|
func (m *WebhookDBManager) openDB(
|
|
webhookID string,
|
|
) (*gorm.DB, error) {
|
|
path := m.dbPath(webhookID)
|
|
dbURL := fmt.Sprintf(
|
|
"file:%s?cache=shared&mode=rwc",
|
|
path,
|
|
)
|
|
|
|
sqlDB, err := sql.Open("sqlite", dbURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"opening webhook database %s: %w",
|
|
webhookID, err,
|
|
)
|
|
}
|
|
|
|
db, err := gorm.Open(sqlite.Dialector{
|
|
Conn: sqlDB,
|
|
}, &gorm.Config{
|
|
// Never leave this at GORM's default. See internal/gormlog.
|
|
Logger: gormlog.New(m.log),
|
|
})
|
|
if err != nil {
|
|
_ = sqlDB.Close()
|
|
|
|
return nil, fmt.Errorf(
|
|
"connecting to webhook database %s: %w",
|
|
webhookID, err,
|
|
)
|
|
}
|
|
|
|
// Run migrations for event-tier models only
|
|
err = db.AutoMigrate(
|
|
&Event{}, &Delivery{}, &DeliveryResult{},
|
|
)
|
|
if err != nil {
|
|
_ = sqlDB.Close()
|
|
|
|
return nil, fmt.Errorf(
|
|
"migrating webhook database %s: %w",
|
|
webhookID, err,
|
|
)
|
|
}
|
|
|
|
m.log.Info(
|
|
"opened per-webhook database",
|
|
"webhook_id", webhookID,
|
|
"path", path,
|
|
)
|
|
|
|
return db, nil
|
|
}
|