All checks were successful
check / check (push) Successful in 3m4s
RetentionDays carried gorm:"default:30", so GORM substituted 30 for a zero value while building the insert. A webhook could therefore never be configured to keep its events indefinitely: the reaper's retain-forever branch existed but was unreachable from the normal create and edit flows. Introduce database.RetentionForeverDays = 365 * 1000 as the sentinel for "retain forever" and a Webhook.BeforeSave hook that rewrites any non-positive RetentionDays to it. The rewrite has to live in the hook rather than at the call sites: GORM applies the column default while converting the model to insert values, which happens after BeforeSave, so anything later loses that race. Putting it on the model also means a future call site, such as the planned REST API, cannot bypass it. The reaper now skips a webhook when Webhook.RetainsForever reports true, which recognises the sentinel and keeps honouring the old <= 0 values for rows written before it existed. Without this the sentinel, being positive, would have produced a cutoff a thousand years in the past and a DELETE matching nothing on every sweep. Form handling is shared by create and edit through parseRetentionDays so the two cannot drift: an empty field keeps the previous behaviour (default on create, unchanged on edit), 0 is honoured, and an unparseable or negative value is a 400 that re-renders the form rather than a silently substituted default. The retention inputs drop max="365". That cap was not cosmetic: the edit form pre-fills the stored value, so a retain-forever webhook rendered 365000 into an input capped at 365 and browser validation would have blocked saving any edit to it. min becomes 0 with a hint explaining what 0 does, and the list and detail views render a RetentionLabel of "forever" instead of a raw day count. The 30-day default is consolidated into database.DefaultRetentionDays, referenced from the handler and from the create form's pre-filled value, with a test asserting it agrees with the struct tag that cannot reference it.
259 lines
5.6 KiB
Go
259 lines
5.6 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/fx"
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
)
|
|
|
|
// hoursPerDay converts a RetentionDays count into hours for cutoff
|
|
// computation.
|
|
const hoursPerDay = 24
|
|
|
|
// RetentionReaperParams holds the fx dependencies for the
|
|
// RetentionReaper.
|
|
type RetentionReaperParams struct {
|
|
fx.In
|
|
|
|
Config *config.Config
|
|
Database *Database
|
|
DBManager *WebhookDBManager
|
|
Logger *logger.Logger
|
|
}
|
|
|
|
// RetentionReaper periodically deletes expired events (and their
|
|
// dependent deliveries and delivery results) from each per-webhook
|
|
// database, enforcing every webhook's RetentionDays. Rows are removed
|
|
// permanently so that per-webhook SQLite files do not grow without
|
|
// bound.
|
|
type RetentionReaper struct {
|
|
db *Database
|
|
dbManager *WebhookDBManager
|
|
log *slog.Logger
|
|
interval time.Duration
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// NewRetentionReaper creates the retention reaper and registers its
|
|
// fx lifecycle hooks. The background sweep loop starts on OnStart and
|
|
// stops cleanly on OnStop via context cancellation.
|
|
func NewRetentionReaper(
|
|
lc fx.Lifecycle,
|
|
params RetentionReaperParams,
|
|
) *RetentionReaper {
|
|
r := &RetentionReaper{
|
|
db: params.Database,
|
|
dbManager: params.DBManager,
|
|
log: params.Logger.Get(),
|
|
interval: params.Config.RetentionSweepInterval,
|
|
}
|
|
|
|
lc.Append(fx.Hook{
|
|
OnStart: func(ctx context.Context) error {
|
|
r.start(ctx)
|
|
|
|
return nil
|
|
},
|
|
OnStop: func(_ context.Context) error {
|
|
r.stop()
|
|
|
|
return nil
|
|
},
|
|
})
|
|
|
|
return r
|
|
}
|
|
|
|
func (r *RetentionReaper) start(ctx context.Context) {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
r.cancel = cancel
|
|
|
|
r.wg.Add(1)
|
|
|
|
go r.run(ctx)
|
|
|
|
r.log.Info(
|
|
"retention reaper started",
|
|
"interval", r.interval.String(),
|
|
)
|
|
}
|
|
|
|
func (r *RetentionReaper) stop() {
|
|
r.log.Info("retention reaper stopping")
|
|
|
|
if r.cancel != nil {
|
|
r.cancel()
|
|
}
|
|
|
|
r.wg.Wait()
|
|
r.log.Info("retention reaper stopped")
|
|
}
|
|
|
|
func (r *RetentionReaper) run(ctx context.Context) {
|
|
defer r.wg.Done()
|
|
|
|
ticker := time.NewTicker(r.interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
r.sweep(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sweep lists every webhook from the main database and reaps expired
|
|
// rows from each per-webhook database that has a finite retention
|
|
// policy. Webhooks set to retain forever are skipped entirely.
|
|
func (r *RetentionReaper) sweep(ctx context.Context) {
|
|
var webhooks []Webhook
|
|
|
|
err := r.db.DB().
|
|
Model(&Webhook{}).
|
|
Find(&webhooks).Error
|
|
if err != nil {
|
|
r.log.Error(
|
|
"retention sweep: failed to list webhooks",
|
|
"error", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
for i := range webhooks {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
wh := webhooks[i]
|
|
|
|
// Skip retain-forever webhooks before building any query.
|
|
// RetainsForever covers both the RetentionForeverDays
|
|
// sentinel and the non-positive values that predate it: the
|
|
// sentinel is a positive number, so without this the reaper
|
|
// would compute a cutoff a thousand years in the past and
|
|
// issue a DELETE matching nothing on every single sweep.
|
|
if wh.RetainsForever() {
|
|
continue
|
|
}
|
|
|
|
// Nothing to reap if the per-webhook database has never
|
|
// been created.
|
|
if !r.dbManager.DBExists(wh.ID) {
|
|
continue
|
|
}
|
|
|
|
r.reapWebhook(wh.ID, wh.RetentionDays)
|
|
}
|
|
}
|
|
|
|
// reapWebhook removes every expired event (and its dependents) from a
|
|
// single webhook's database.
|
|
func (r *RetentionReaper) reapWebhook(
|
|
webhookID string,
|
|
retentionDays int,
|
|
) {
|
|
db, err := r.dbManager.GetDB(webhookID)
|
|
if err != nil {
|
|
r.log.Error(
|
|
"retention sweep: failed to open webhook database",
|
|
"webhook_id", webhookID,
|
|
"error", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
cutoff := time.Now().Add(
|
|
-time.Duration(retentionDays*hoursPerDay) * time.Hour,
|
|
)
|
|
|
|
deleted, err := reapExpired(db, cutoff)
|
|
if err != nil {
|
|
r.log.Error(
|
|
"retention sweep: failed to reap expired events",
|
|
"webhook_id", webhookID,
|
|
"error", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if deleted > 0 {
|
|
r.log.Info(
|
|
"retention sweep: reaped expired events",
|
|
"webhook_id", webhookID,
|
|
"retention_days", retentionDays,
|
|
"events_deleted", deleted,
|
|
)
|
|
}
|
|
}
|
|
|
|
// reapExpired hard-deletes, in foreign-key-safe order, the delivery
|
|
// results, deliveries, and events associated with events older than
|
|
// cutoff. Deletes are unscoped so rows are physically removed rather
|
|
// than soft-deleted, reclaiming disk. It returns the number of events
|
|
// deleted.
|
|
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
|
// Fresh subqueries are built per statement to avoid reusing a
|
|
// mutated builder across executions.
|
|
expiredEventIDs := func() *gorm.DB {
|
|
return db.Model(&Event{}).
|
|
Select("id").
|
|
Where("created_at < ?", cutoff)
|
|
}
|
|
expiredDeliveryIDs := func() *gorm.DB {
|
|
return db.Model(&Delivery{}).
|
|
Select("id").
|
|
Where("event_id IN (?)", expiredEventIDs())
|
|
}
|
|
|
|
// 1. Delivery results whose delivery belongs to an expired event.
|
|
res := db.Unscoped().
|
|
Where("delivery_id IN (?)", expiredDeliveryIDs()).
|
|
Delete(&DeliveryResult{})
|
|
if res.Error != nil {
|
|
return 0, fmt.Errorf(
|
|
"deleting expired delivery results: %w",
|
|
res.Error,
|
|
)
|
|
}
|
|
|
|
// 2. Deliveries belonging to an expired event.
|
|
del := db.Unscoped().
|
|
Where("event_id IN (?)", expiredEventIDs()).
|
|
Delete(&Delivery{})
|
|
if del.Error != nil {
|
|
return 0, fmt.Errorf(
|
|
"deleting expired deliveries: %w",
|
|
del.Error,
|
|
)
|
|
}
|
|
|
|
// 3. The expired events themselves.
|
|
ev := db.Unscoped().
|
|
Where("created_at < ?", cutoff).
|
|
Delete(&Event{})
|
|
if ev.Error != nil {
|
|
return 0, fmt.Errorf(
|
|
"deleting expired events: %w",
|
|
ev.Error,
|
|
)
|
|
}
|
|
|
|
return ev.RowsAffected, nil
|
|
}
|