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.
403 lines
9.6 KiB
Go
403 lines
9.6 KiB
Go
package delivery_test
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
)
|
|
|
|
func archiveTestLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
))
|
|
}
|
|
|
|
// openArchiveDBForRead opens an archive file read-only so a
|
|
// test can inspect the rows the writer persisted.
|
|
func openArchiveDBForRead(
|
|
t *testing.T, path string,
|
|
) *gorm.DB {
|
|
t.Helper()
|
|
|
|
sqlDB, err := sql.Open(
|
|
"sqlite",
|
|
fmt.Sprintf("file:%s?mode=ro", path),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
|
|
gdb, err := gorm.Open(
|
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
return gdb
|
|
}
|
|
|
|
// archiveFileSuffixes returns the archive file itself and the
|
|
// SQLite sidecars that accompany an open database. A test that
|
|
// asserts no archive was created has to check all of them.
|
|
func archiveFileSuffixes() []string {
|
|
return []string{"", "-wal", "-shm"}
|
|
}
|
|
|
|
// removeArchiveFiles simulates an operator moving the archive
|
|
// away by deleting the SQLite file and its sidecar files.
|
|
func removeArchiveFiles(t *testing.T, path string) {
|
|
t.Helper()
|
|
|
|
for _, suffix := range []string{
|
|
"", "-wal", "-shm", "-journal",
|
|
} {
|
|
err := os.Remove(path + suffix)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
t.Fatalf("removing %s%s: %v", path, suffix, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDeliverDatabase_ArchivesEvent verifies that delivering to
|
|
// a database target marks the delivery delivered and archives
|
|
// the full event into a separate per-webhook archive file.
|
|
func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataDir := t.TempDir()
|
|
dbMgr := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
e := delivery.NewTestEngineWithDB(
|
|
nil, dbMgr,
|
|
archiveTestLogger(),
|
|
&http.Client{Timeout: 5 * time.Second},
|
|
1,
|
|
)
|
|
|
|
webhookDB := testWebhookDB(t)
|
|
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
|
|
|
e.ExportDeliverDatabase(webhookDB, d)
|
|
|
|
var updated database.Delivery
|
|
|
|
require.NoError(t, webhookDB.First(
|
|
&updated, "id = ?", d.ID,
|
|
).Error)
|
|
assert.Equal(t,
|
|
database.DeliveryStatusDelivered, updated.Status,
|
|
"database target should mark the delivery delivered",
|
|
)
|
|
|
|
archivePath := filepath.Join(
|
|
dataDir,
|
|
fmt.Sprintf("archive-%s.db", event.WebhookID),
|
|
)
|
|
assert.FileExists(t, archivePath)
|
|
|
|
rdb := openArchiveDBForRead(t, archivePath)
|
|
|
|
var rows []delivery.ExportArchivedEvent
|
|
|
|
require.NoError(t, rdb.Find(&rows).Error)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, event.ID, rows[0].EventID)
|
|
assert.Equal(t, event.WebhookID, rows[0].WebhookID)
|
|
assert.Equal(t, event.Method, rows[0].Method)
|
|
assert.JSONEq(t, `{"archived":true}`, rows[0].Body)
|
|
}
|
|
|
|
func TestArchiveWriter_WritesRow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
|
w := delivery.NewExportArchiveWriter(
|
|
path, archiveTestLogger(), 0,
|
|
)
|
|
|
|
row := delivery.ExportArchivedEvent{
|
|
EventID: "ev-1",
|
|
WebhookID: "wh-1",
|
|
EntrypointID: "ep-1",
|
|
Method: "POST",
|
|
Headers: `{"X":"Y"}`,
|
|
Body: `{"hello":"world"}`,
|
|
ContentType: "application/json",
|
|
}
|
|
|
|
require.NoError(t, w.Write(row, 0))
|
|
assert.FileExists(t, path)
|
|
|
|
var got []delivery.ExportArchivedEvent
|
|
|
|
require.NoError(t, w.DB().Find(&got).Error)
|
|
require.Len(t, got, 1)
|
|
assert.Equal(t, "ev-1", got[0].EventID)
|
|
assert.Equal(t, "wh-1", got[0].WebhookID)
|
|
assert.Equal(t, "ep-1", got[0].EntrypointID)
|
|
assert.Equal(t, row.Method, got[0].Method)
|
|
assert.Equal(t, row.ContentType, got[0].ContentType)
|
|
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
|
|
assert.False(t, got[0].ArchivedAt.IsZero())
|
|
}
|
|
|
|
func TestArchiveWriter_RecreatesAfterRemoval(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
|
w := delivery.NewExportArchiveWriter(
|
|
path, archiveTestLogger(), 0,
|
|
)
|
|
|
|
require.NoError(t, w.Write(
|
|
delivery.ExportArchivedEvent{EventID: "a"}, 0,
|
|
))
|
|
assert.FileExists(t, path)
|
|
|
|
// The operator moves the archive away while the handle is
|
|
// still open.
|
|
removeArchiveFiles(t, path)
|
|
require.NoFileExists(t, path)
|
|
|
|
// The next write recreates the file with a fresh schema and
|
|
// only the new row.
|
|
require.NoError(t, w.Write(
|
|
delivery.ExportArchivedEvent{EventID: "b"}, 0,
|
|
))
|
|
assert.FileExists(t, path)
|
|
|
|
var got []delivery.ExportArchivedEvent
|
|
|
|
require.NoError(t, w.DB().Find(&got).Error)
|
|
require.Len(t, got, 1)
|
|
assert.Equal(t, "b", got[0].EventID)
|
|
}
|
|
|
|
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A generous debounce keeps the two rapid writes inside
|
|
// the window even on a heavily loaded test machine.
|
|
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
|
w := delivery.NewExportArchiveWriter(
|
|
path, archiveTestLogger(), 2*time.Second,
|
|
)
|
|
|
|
require.NoError(t, w.Write(
|
|
delivery.ExportArchivedEvent{EventID: "a"}, 0,
|
|
))
|
|
require.NoError(t, w.Write(
|
|
delivery.ExportArchivedEvent{EventID: "b"}, 0,
|
|
))
|
|
|
|
// Two writes inside the debounce window trigger only the
|
|
// initial open — no extra close/reopen.
|
|
assert.Equal(t, 1, w.Reopens())
|
|
|
|
time.Sleep(2100 * time.Millisecond)
|
|
|
|
require.NoError(t, w.Write(
|
|
delivery.ExportArchivedEvent{EventID: "c"}, 0,
|
|
))
|
|
|
|
// A write after the window elapses closes and reopens once.
|
|
assert.Equal(t, 2, w.Reopens())
|
|
}
|
|
|
|
func TestArchiveWriter_ExpiryPrune(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
|
w := delivery.NewExportArchiveWriter(
|
|
path, archiveTestLogger(), 0,
|
|
)
|
|
|
|
require.NoError(t, w.Open(0))
|
|
|
|
old := delivery.ExportArchivedEvent{
|
|
EventID: "old",
|
|
ArchivedAt: time.Now().Add(-2 * time.Hour),
|
|
}
|
|
fresh := delivery.ExportArchivedEvent{
|
|
EventID: "fresh",
|
|
ArchivedAt: time.Now(),
|
|
}
|
|
|
|
require.NoError(t, w.DB().Create(&old).Error)
|
|
require.NoError(t, w.DB().Create(&fresh).Error)
|
|
|
|
// Reopening with a one-hour expiry prunes the old row.
|
|
require.NoError(t, w.Reopen(time.Hour))
|
|
|
|
var got []delivery.ExportArchivedEvent
|
|
|
|
require.NoError(t, w.DB().Find(&got).Error)
|
|
require.Len(t, got, 1)
|
|
assert.Equal(t, "fresh", got[0].EventID)
|
|
}
|
|
|
|
func TestParseArchiveExpiry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want time.Duration
|
|
wantErr bool
|
|
}{
|
|
{"empty config", "", 0, false},
|
|
{"explicit never", `{"expiry":"never"}`, 0, false},
|
|
{"empty expiry", `{"expiry":""}`, 0, false},
|
|
{"duration", `{"expiry":"1h"}`, time.Hour, false},
|
|
{"unparseable", `{"expiry":"nonsense"}`, 0, true},
|
|
{"zero duration", `{"expiry":"0s"}`, 0, true},
|
|
{"negative duration", `{"expiry":"-5h"}`, 0, true},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := delivery.ExportParseArchiveExpiry(tc.in)
|
|
if tc.wantErr {
|
|
require.Error(t, err)
|
|
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// seedDatabaseTargetDelivery seeds a pending delivery for a
|
|
// database target with the given config JSON and returns the
|
|
// in-memory delivery the target handler is invoked with.
|
|
func seedDatabaseTargetDelivery(
|
|
t *testing.T,
|
|
webhookDB *gorm.DB,
|
|
event database.Event,
|
|
config string,
|
|
) *database.Delivery {
|
|
t.Helper()
|
|
|
|
dlv := seedDelivery(
|
|
t, webhookDB, event.ID, uuid.New().String(),
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
d := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: dlv.TargetID,
|
|
Status: database.DeliveryStatusPending,
|
|
Event: event,
|
|
Target: database.Target{
|
|
Name: "test-db",
|
|
Type: database.TargetTypeDatabase,
|
|
Config: config,
|
|
},
|
|
}
|
|
d.ID = dlv.ID
|
|
|
|
return d
|
|
}
|
|
|
|
// TestDeliverDatabase_ArchiveFailureFailsDelivery verifies that
|
|
// an archive error (here: an unparseable expiry in the target
|
|
// config) fails the delivery loudly: the attempt is recorded as
|
|
// failed with the error and the delivery is marked failed, not
|
|
// delivered.
|
|
func TestDeliverDatabase_ArchiveFailureFailsDelivery(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
dataDir := t.TempDir()
|
|
|
|
e := delivery.NewTestEngineWithDB(
|
|
nil, database.NewTestWebhookDBManager(dataDir),
|
|
archiveTestLogger(),
|
|
&http.Client{Timeout: 5 * time.Second},
|
|
1,
|
|
)
|
|
|
|
webhookDB := testWebhookDB(t)
|
|
event := seedEvent(t, webhookDB, `{"archived":false}`)
|
|
d := seedDatabaseTargetDelivery(
|
|
t, webhookDB, event, `{"expiry":"nonsense"}`,
|
|
)
|
|
|
|
e.ExportDeliverDatabase(webhookDB, d)
|
|
|
|
var updated database.Delivery
|
|
|
|
require.NoError(t, webhookDB.First(
|
|
&updated, "id = ?", d.ID,
|
|
).Error)
|
|
assert.Equal(t,
|
|
database.DeliveryStatusFailed, updated.Status,
|
|
"archive failure must mark the delivery failed",
|
|
)
|
|
|
|
var results []database.DeliveryResult
|
|
|
|
require.NoError(t, webhookDB.Where(
|
|
"delivery_id = ?", d.ID,
|
|
).Find(&results).Error)
|
|
require.Len(t, results, 1)
|
|
assert.False(t,
|
|
results[0].Success,
|
|
"the attempt must be recorded as failed",
|
|
)
|
|
assert.Contains(t,
|
|
results[0].Error, "nonsense",
|
|
"the archive error must be recorded on the attempt",
|
|
)
|
|
|
|
assert.NoFileExists(t,
|
|
filepath.Join(
|
|
dataDir,
|
|
fmt.Sprintf("archive-%s.db", event.WebhookID),
|
|
),
|
|
"no archive file should exist for a failed config",
|
|
)
|
|
}
|
|
|
|
func TestValidateArchiveExpiry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
valid := []string{"", "never", "1h", "720h", "30m"}
|
|
for _, in := range valid {
|
|
require.NoError(t,
|
|
delivery.ValidateArchiveExpiry(in),
|
|
"expiry %q should be accepted", in,
|
|
)
|
|
}
|
|
|
|
invalid := []string{"nonsense", "7d", "-5h", "0s", "0"}
|
|
for _, in := range invalid {
|
|
require.Error(t,
|
|
delivery.ValidateArchiveExpiry(in),
|
|
"expiry %q should be rejected", in,
|
|
)
|
|
}
|
|
}
|