All checks were successful
check / check (push) Successful in 2m56s
The per-webhook archiveWriter registry in the database delivery target was never evicted, so a deleted webhook's writer -- and any archive file handle open within its debounce window -- lingered for the process lifetime. Separately, expiry pruning ran only when an archive was (re)opened, and reopens only happen on writes, so an archive belonging to a webhook that stopped receiving events kept its expired rows forever. Eviction: a new one-method delivery.WebhookEvictor interface (kept separate from Notifier: archiving lifecycle is not notification) is implemented by the Engine and injected into the handlers. Deleting a webhook, or deleting its last database target, drops the writer from the registry and closes its handle under the writer's own mutex, so eviction can never race an in-flight write. An evicted writer refuses further writes rather than reopening a file nothing holds. The archive file is deliberately left on disk: it is long-term storage an operator may want to keep or move away, and destroying it as a side effect of deleting a webhook would be unrecoverable. Idle sweep: a new ArchiveSweeper, modelled on the event RetentionReaper (fx lifecycle hooks, cancellable context, WaitGroup, ticker loop), prunes archives whose database target declares a positive expiry. It reuses the existing RETENTION_SWEEP_INTERVAL rather than adding a config key. It never creates an archive -- a missing file is skipped, and the reopen uses SQLite mode=rw so the file cannot be conjured even if it disappears mid-sweep -- routes the prune through the per-webhook writer so its mutex orders the sweep against concurrent writes, and leaves the archive closed so the move-the-file-away workflow keeps working. A failure for one webhook is logged and the sweep continues. Archives with no expiry or the expiry "never" are untouched. The sweep loop's context is rooted at context.Background(), not at the fx OnStart hook context. The hook context carries fx's 15 second start timeout, so a loop derived from it is cancelled three quarters of an hour before the first tick under the default one-hour interval, giving a sweeper that never sweeps. OnStop still cancels the loop and waits on the WaitGroup, so shutdown is unchanged. The sweep also never leaves a registry entry behind. Reaching the writer through the ordinary create-and-cache accessor would let a sweep that raced a webhook deletion re-insert a writer for a webhook that no longer exists, which nothing would ever evict again -- the very leak this change closes. An entry the sweep has to create is marked sweep-owned and released when the prune finishes, unless a delivery claimed it meanwhile, in which case it belongs to the registry and an eviction can still reach it. A writer evicted underneath a sweep is an ordinary interleaving and is logged at debug, not error.
430 lines
11 KiB
Go
430 lines
11 KiB
Go
package delivery
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// archiveExpiryNever is the expiry sentinel (and default) that
|
|
// disables pruning so archived rows are kept forever.
|
|
const archiveExpiryNever = "never"
|
|
|
|
// archiveReopenDebounce bounds how often an archive file is
|
|
// closed and reopened. After each write the handle is closed
|
|
// and reopened so an operator can move the file away for
|
|
// offline archiving, but never more than once per this window.
|
|
const archiveReopenDebounce = time.Second
|
|
|
|
const (
|
|
// archiveModeCreate is the SQLite URI mode used by the write
|
|
// path: open the archive file, creating it if missing, so a
|
|
// first write (or a write after the operator moved the file
|
|
// away) recreates it.
|
|
archiveModeCreate = "rwc"
|
|
|
|
// archiveModeExisting is the SQLite URI mode used by the idle
|
|
// sweep: open read-write but never create. A sweep must never
|
|
// conjure an empty archive file for a webhook that has a
|
|
// database target but has never received an event.
|
|
archiveModeExisting = "rw"
|
|
)
|
|
|
|
var (
|
|
// errArchiveMissingWebhookID is returned when an event to
|
|
// archive has no webhook id to key its archive file on.
|
|
errArchiveMissingWebhookID = errors.New(
|
|
"cannot archive event without a webhook id",
|
|
)
|
|
|
|
// errArchiveNoDataDir is returned when the database target
|
|
// has no webhook database manager and so cannot locate the
|
|
// data directory for archive files.
|
|
errArchiveNoDataDir = errors.New(
|
|
"database target has no data directory",
|
|
)
|
|
|
|
// errArchiveExpiryNotPositive is returned when a
|
|
// user-supplied archive expiry parses as a duration but is
|
|
// zero or negative; "never" is the way to disable pruning.
|
|
errArchiveExpiryNotPositive = errors.New(
|
|
"expiry must be a positive duration or \"never\"",
|
|
)
|
|
|
|
// errArchiveWriterEvicted is returned when a writer that has
|
|
// been evicted (its webhook was deleted, or its last database
|
|
// target was removed) is used again. An evicted writer is no
|
|
// longer in the registry, so reopening its file would leak a
|
|
// handle nothing owns.
|
|
errArchiveWriterEvicted = errors.New(
|
|
"archive writer has been evicted",
|
|
)
|
|
)
|
|
|
|
// databaseTargetConfig is the optional per-target JSON config
|
|
// for a database (archive) target.
|
|
type databaseTargetConfig struct {
|
|
// Expiry is a Go duration (e.g. "720h") after which
|
|
// archived rows are pruned, or "never" (the default) to
|
|
// keep them forever.
|
|
Expiry string `json:"expiry"`
|
|
}
|
|
|
|
// archivedEvent is one fully captured webhook event stored in a
|
|
// per-webhook archive database for long-term retention. It is a
|
|
// self-contained copy — independent of the per-webhook event
|
|
// database, which may prune events under its own retention.
|
|
type archivedEvent struct {
|
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
|
EventID string `gorm:"index"`
|
|
WebhookID string
|
|
EntrypointID string
|
|
Method string
|
|
Headers string
|
|
Body string
|
|
ContentType string
|
|
|
|
// ArchivedAt is when the row was archived and is the age
|
|
// basis for expiry pruning.
|
|
ArchivedAt time.Time `gorm:"index"`
|
|
}
|
|
|
|
// parseArchiveExpiry reads the optional expiry from a database
|
|
// target's config JSON. An empty config, an empty expiry, or
|
|
// the literal "never" all mean keep forever, returned as a zero
|
|
// duration. Any other value must parse as a positive Go
|
|
// duration; a set-but-invalid value (unparseable, zero, or
|
|
// negative) is an error rather than a silent default, matching
|
|
// ValidateArchiveExpiry at target creation.
|
|
func parseArchiveExpiry(
|
|
configJSON string,
|
|
) (time.Duration, error) {
|
|
if configJSON == "" {
|
|
return 0, nil
|
|
}
|
|
|
|
var cfg databaseTargetConfig
|
|
|
|
err := json.Unmarshal([]byte(configJSON), &cfg)
|
|
if err != nil {
|
|
return 0, fmt.Errorf(
|
|
"parsing database target config: %w", err,
|
|
)
|
|
}
|
|
|
|
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
|
|
return 0, nil
|
|
}
|
|
|
|
dur, err := time.ParseDuration(cfg.Expiry)
|
|
if err != nil {
|
|
return 0, fmt.Errorf(
|
|
"parsing archive expiry %q: %w", cfg.Expiry, err,
|
|
)
|
|
}
|
|
|
|
if dur <= 0 {
|
|
return 0, fmt.Errorf(
|
|
"%w: %q", errArchiveExpiryNotPositive, cfg.Expiry,
|
|
)
|
|
}
|
|
|
|
return dur, nil
|
|
}
|
|
|
|
// ValidateArchiveExpiry checks a user-supplied archive expiry
|
|
// for a database target at configuration time. Valid values are
|
|
// empty, "never" (both meaning keep forever), or a positive Go
|
|
// duration such as "720h". Anything else is an error, so a bad
|
|
// expiry is rejected when the target is created rather than
|
|
// failing every subsequent delivery.
|
|
func ValidateArchiveExpiry(expiry string) error {
|
|
if expiry == "" || expiry == archiveExpiryNever {
|
|
return nil
|
|
}
|
|
|
|
dur, err := time.ParseDuration(expiry)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"expiry must be %q or a Go duration "+
|
|
"such as \"720h\": %w",
|
|
archiveExpiryNever, err,
|
|
)
|
|
}
|
|
|
|
if dur <= 0 {
|
|
return fmt.Errorf(
|
|
"%w: %q", errArchiveExpiryNotPositive, expiry,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// archiveWriter owns one per-webhook archive SQLite file. It
|
|
// serialises writes, and after each write closes and reopens
|
|
// the file (debounced to at most once per debounce window) so
|
|
// an operator can move the file away for offline archiving. The
|
|
// next write recreates a moved or removed file, because the
|
|
// file is opened create-if-missing and its schema is migrated
|
|
// on every open.
|
|
type archiveWriter struct {
|
|
mu sync.Mutex
|
|
path string
|
|
log *slog.Logger
|
|
debounce time.Duration
|
|
db *gorm.DB
|
|
lastReopen time.Time
|
|
reopens int
|
|
|
|
// evicted marks a writer that has been removed from the
|
|
// per-webhook registry. Its handle is closed and it must
|
|
// never open the file again: nothing holds it any more, so a
|
|
// reopen would leak the handle for the process lifetime.
|
|
evicted bool
|
|
|
|
// sweepOwned marks a registry entry that the idle sweep
|
|
// created because no writer was cached for the webhook. The
|
|
// sweep removes such an entry again when it is done, so a
|
|
// sweep can never leave — or resurrect — a registry entry
|
|
// for a webhook that has been deleted. A delivery that adopts
|
|
// the writer clears the flag, handing the entry to the
|
|
// registry proper.
|
|
//
|
|
// Unlike every other field here it is guarded by
|
|
// databaseTarget.mu, not by this writer's mu: it describes the
|
|
// registry entry rather than the file.
|
|
sweepOwned bool
|
|
}
|
|
|
|
// newArchiveWriter builds an archiveWriter for a file path with
|
|
// the default reopen debounce.
|
|
func newArchiveWriter(
|
|
path string, log *slog.Logger,
|
|
) *archiveWriter {
|
|
return &archiveWriter{
|
|
path: path,
|
|
log: log,
|
|
debounce: archiveReopenDebounce,
|
|
}
|
|
}
|
|
|
|
// write appends the event as a row, then applies the debounced
|
|
// close/reopen. It recreates the archive file if it was moved
|
|
// or removed since the last open. A positive expiry prunes rows
|
|
// older than it on each (re)open.
|
|
func (w *archiveWriter) write(
|
|
row archivedEvent, expiry time.Duration,
|
|
) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
if w.evicted {
|
|
return fmt.Errorf(
|
|
"%w: %s", errArchiveWriterEvicted, w.path,
|
|
)
|
|
}
|
|
|
|
if w.db == nil || !fileExists(w.path) {
|
|
err := w.reopen(expiry)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
row.ArchivedAt = time.Now()
|
|
|
|
err := w.db.Create(&row).Error
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"archiving event to %s: %w", w.path, err,
|
|
)
|
|
}
|
|
|
|
if time.Since(w.lastReopen) >= w.debounce {
|
|
return w.reopen(expiry)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// open opens (creating if missing) the archive file, migrates
|
|
// its schema, records the reopen time, and prunes expired rows
|
|
// when expiry is positive.
|
|
func (w *archiveWriter) open(expiry time.Duration) error {
|
|
return w.openMode(archiveModeCreate, expiry)
|
|
}
|
|
|
|
// openMode opens the archive file with the given SQLite URI
|
|
// mode, migrates its schema, records the reopen time, and
|
|
// prunes expired rows when expiry is positive. The write path
|
|
// passes archiveModeCreate so a missing file is recreated; the
|
|
// idle sweep passes archiveModeExisting so a missing file is an
|
|
// error rather than a newly conjured empty archive.
|
|
func (w *archiveWriter) openMode(
|
|
mode string, expiry time.Duration,
|
|
) error {
|
|
dbURL := fmt.Sprintf("file:%s?mode=%s", w.path, mode)
|
|
|
|
sqlDB, err := sql.Open("sqlite", dbURL)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"opening archive database %s: %w", w.path, err,
|
|
)
|
|
}
|
|
|
|
gdb, err := gorm.Open(
|
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
|
)
|
|
if err != nil {
|
|
_ = sqlDB.Close()
|
|
|
|
return fmt.Errorf(
|
|
"connecting to archive database %s: %w",
|
|
w.path, err,
|
|
)
|
|
}
|
|
|
|
err = gdb.AutoMigrate(&archivedEvent{})
|
|
if err != nil {
|
|
_ = sqlDB.Close()
|
|
|
|
return fmt.Errorf(
|
|
"migrating archive database %s: %w", w.path, err,
|
|
)
|
|
}
|
|
|
|
w.db = gdb
|
|
w.lastReopen = time.Now()
|
|
w.reopens++
|
|
|
|
if expiry > 0 {
|
|
w.prune(expiry)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// reopen closes any open handle and opens the file afresh. The
|
|
// fresh open recreates the file if it was moved away.
|
|
func (w *archiveWriter) reopen(expiry time.Duration) error {
|
|
w.close()
|
|
|
|
return w.open(expiry)
|
|
}
|
|
|
|
// close closes the underlying handle, if any.
|
|
func (w *archiveWriter) close() {
|
|
if w.db == nil {
|
|
return
|
|
}
|
|
|
|
sqlDB, err := w.db.DB()
|
|
if err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
|
|
w.db = nil
|
|
}
|
|
|
|
// sweepExpired prunes an archive that may have gone idle, with
|
|
// no write to trigger the usual on-reopen prune. It takes the
|
|
// writer's own mutex for the whole operation, so a sweep is
|
|
// ordered against concurrent writes rather than reaching around
|
|
// them to the file.
|
|
//
|
|
// It never creates the archive file: a missing file is skipped,
|
|
// and the reopen uses archiveModeExisting so SQLite itself
|
|
// refuses to create one if the file disappears between the
|
|
// check and the open.
|
|
//
|
|
// The archive is left CLOSED afterwards. An idle archive holding
|
|
// no handle is what keeps the operator's move-the-file-away
|
|
// workflow working; the next write reopens (and recreates) the
|
|
// file as it always has.
|
|
func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
if w.evicted {
|
|
return fmt.Errorf(
|
|
"%w: %s", errArchiveWriterEvicted, w.path,
|
|
)
|
|
}
|
|
|
|
if !fileExists(w.path) {
|
|
return nil
|
|
}
|
|
|
|
// Drop any live handle first so the prune runs against a
|
|
// freshly opened file, matching the write path's semantics.
|
|
w.close()
|
|
|
|
err := w.openMode(archiveModeExisting, expiry)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
w.close()
|
|
|
|
return nil
|
|
}
|
|
|
|
// evict closes the writer's handle and marks it unusable. It is
|
|
// called when the writer leaves the registry, either because the
|
|
// webhook was deleted or because its last database target was
|
|
// removed. The archive FILE is deliberately left on disk: it is
|
|
// long-term storage an operator may still want.
|
|
func (w *archiveWriter) evict() {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
w.evicted = true
|
|
|
|
w.close()
|
|
}
|
|
|
|
// prune deletes archived rows older than expiry, measured from
|
|
// each row's archived time. It runs on every (re)open, and
|
|
// because the file is reopened after writes this keeps the
|
|
// archive swept without a separate background sweeper. Failures
|
|
// are logged, not fatal: a prune error must not stop archiving.
|
|
func (w *archiveWriter) prune(expiry time.Duration) {
|
|
cutoff := time.Now().Add(-expiry)
|
|
|
|
res := w.db.Where("archived_at < ?", cutoff).
|
|
Delete(&archivedEvent{})
|
|
if res.Error != nil {
|
|
w.log.Error(
|
|
"failed to prune expired archive rows",
|
|
"path", w.path,
|
|
"error", res.Error,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if res.RowsAffected > 0 {
|
|
w.log.Info(
|
|
"pruned expired archive rows",
|
|
"path", w.path,
|
|
"rows_deleted", res.RowsAffected,
|
|
)
|
|
}
|
|
}
|
|
|
|
// fileExists reports whether a path currently exists.
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
|
|
return err == nil
|
|
}
|