check / check (push) Waiting to run
The SQLite archive files the database target writes are now managed by internal/archive: file names, rotation, the writer of each target, pruning, renames, the gzipped JSON export, the idle sweep, and the rename lock, which was Handlers.renameMu. The database target stays in internal/delivery and writes through archive.Writers; the engine still closes the writers when it stops, so the stop order is unchanged. The handlers take the rename lock from archive.Archives.RenameLock, and rename and evict through archive.Archives. Exported names drop their Archive prefix in the new package. The tests moved with the code; the two that check a delivery's recorded attempt stay in delivery. Model: opus-5-5
254 lines
6.0 KiB
Go
254 lines
6.0 KiB
Go
package archive
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/fx"
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/lifecycle"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
)
|
|
|
|
// SweeperParams holds the fx dependencies for the
|
|
// Sweeper.
|
|
type SweeperParams struct {
|
|
fx.In
|
|
|
|
Config *config.Config
|
|
Database *database.Database
|
|
Writers *Writers
|
|
Logger *logger.Logger
|
|
}
|
|
|
|
// Sweeper periodically prunes expired rows from the
|
|
// archive databases of database targets that carry a positive
|
|
// expiry.
|
|
//
|
|
// Without it, pruning happens only when an archive is
|
|
// (re)opened, and archives are only ever reopened by writes: an
|
|
// archive whose target has stopped receiving events would keep
|
|
// its expired rows forever. The sweep closes
|
|
// that gap without changing anything for archives whose expiry
|
|
// is unset or "never".
|
|
//
|
|
// It reuses Config.RetentionSweepInterval rather than
|
|
// introducing a second interval: this is a retention sweep with
|
|
// the same semantics as the event retention reaper.
|
|
type Sweeper struct {
|
|
db *database.Database
|
|
writers *Writers
|
|
log *slog.Logger
|
|
interval time.Duration
|
|
|
|
// cancel needs no lock: fx calls the stop hook only after the
|
|
// start hook has returned, so stop never reads it while start
|
|
// is still setting it.
|
|
cancel context.CancelFunc
|
|
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// NewSweeper creates the archive sweeper and registers
|
|
// its fx lifecycle hooks. The background sweep loop starts on
|
|
// OnStart and stops cleanly on OnStop via context cancellation.
|
|
func NewSweeper(
|
|
lc fx.Lifecycle,
|
|
params SweeperParams,
|
|
) *Sweeper {
|
|
s := &Sweeper{
|
|
db: params.Database,
|
|
writers: params.Writers,
|
|
log: params.Logger.Get(),
|
|
interval: params.Config.RetentionSweepInterval,
|
|
}
|
|
|
|
s.registerHooks(lc)
|
|
|
|
return s
|
|
}
|
|
|
|
// registerHooks wires the sweeper's start and stop into the fx
|
|
// lifecycle. The start hook's context is deliberately ignored
|
|
// (see start for why the background loop must not inherit it);
|
|
// the stop hook's context is honoured (see stop).
|
|
func (s *Sweeper) registerHooks(lc fx.Lifecycle) {
|
|
lc.Append(fx.Hook{
|
|
//nolint:contextcheck // Not passing the hook context is
|
|
// the point: see start.
|
|
OnStart: func(_ context.Context) error {
|
|
s.start()
|
|
|
|
return nil
|
|
},
|
|
OnStop: func(ctx context.Context) error {
|
|
return s.stop(ctx)
|
|
},
|
|
})
|
|
}
|
|
|
|
// start launches the background sweep loop.
|
|
//
|
|
// The loop's context is derived from context.Background(), NOT
|
|
// from the fx OnStart hook context. The hook context carries
|
|
// fx's start timeout (15s by default), so a loop derived from it
|
|
// is cancelled 15 seconds after the application starts — long
|
|
// before the first tick under the default one-hour sweep
|
|
// interval, leaving a sweeper that never sweeps. A long-lived
|
|
// goroutine must outlive the startup phase, so its lifetime is
|
|
// bounded by OnStop instead: stop cancels this context and waits
|
|
// on the WaitGroup.
|
|
func (s *Sweeper) start() {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s.cancel = cancel
|
|
|
|
s.wg.Add(1)
|
|
|
|
go s.run(ctx)
|
|
|
|
s.log.Info(
|
|
"archive sweeper started",
|
|
"interval", s.interval.String(),
|
|
)
|
|
}
|
|
|
|
// stop cancels the sweep loop's context and waits for it to
|
|
// exit, bounded by the stop hook's context: a prune wedged on a
|
|
// locked archive must not hang the process past fx's stop
|
|
// timeout.
|
|
func (s *Sweeper) stop(ctx context.Context) error {
|
|
s.log.Info("archive sweeper stopping")
|
|
|
|
if s.cancel != nil {
|
|
s.cancel()
|
|
}
|
|
|
|
err := lifecycle.WaitForShutdown(
|
|
ctx, s.log, "archive sweeper", &s.wg,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.log.Info("archive sweeper stopped")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Sweeper) run(ctx context.Context) {
|
|
defer s.wg.Done()
|
|
|
|
ticker := time.NewTicker(s.interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.sweep(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sweep prunes every archive whose database target declares a
|
|
// positive expiry. Targets belonging to a deleted webhook are
|
|
// soft-deleted along with it, so GORM's default scope already
|
|
// excludes them.
|
|
//
|
|
// A failure for one target is logged and the sweep continues,
|
|
// matching how the write path already treats a prune error as
|
|
// non-fatal.
|
|
func (s *Sweeper) sweep(ctx context.Context) {
|
|
var targets []database.Target
|
|
|
|
err := s.db.DB().
|
|
WithContext(ctx).
|
|
Model(&database.Target{}).
|
|
Where("type = ?", database.TargetTypeDatabase).
|
|
Find(&targets).Error
|
|
if err != nil {
|
|
// The app stopping as a sweep starts cancels the listing.
|
|
// Stopping is not a failure, so it must not produce an
|
|
// error line.
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
s.log.Error(
|
|
"archive sweep: failed to list database targets",
|
|
"error", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
for i := range targets {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
s.sweepTarget(&targets[i])
|
|
}
|
|
}
|
|
|
|
// sweepTarget prunes the archive of a single database target.
|
|
// A missing, empty, or "never" expiry parses as a zero duration
|
|
// and is skipped entirely, so those archives are never pruned.
|
|
func (s *Sweeper) sweepTarget(target *database.Target) {
|
|
expiry, err := ParseExpiry(target.Config)
|
|
if err != nil {
|
|
s.log.Error(
|
|
"archive sweep: invalid database target config",
|
|
"webhook_id", target.WebhookID,
|
|
"target_id", target.ID,
|
|
"error", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if expiry <= 0 {
|
|
return
|
|
}
|
|
|
|
if s.writers == nil {
|
|
return
|
|
}
|
|
|
|
err = s.writers.sweepArchive(target.ID, expiry)
|
|
if err == nil {
|
|
return
|
|
}
|
|
|
|
// A writer evicted, or a target row gone, underneath the sweep
|
|
// means the operator deleted the target or its webhook while
|
|
// the sweep was walking the target list. That is an ordinary
|
|
// interleaving, not a failure, so it must not produce an
|
|
// error line.
|
|
if errors.Is(err, errArchiveWriterEvicted) ||
|
|
errors.Is(err, gorm.ErrRecordNotFound) {
|
|
s.log.Debug(
|
|
"archive sweep: target deleted mid-sweep",
|
|
"webhook_id", target.WebhookID,
|
|
"target_id", target.ID,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
s.log.Error(
|
|
"archive sweep: failed to prune archive",
|
|
"webhook_id", target.WebhookID,
|
|
"target_id", target.ID,
|
|
"error", err,
|
|
)
|
|
}
|