All checks were successful
check / check (push) Successful in 3m10s
/metrics carried only the inbound HTTP surface, so a destination failing for an hour, a growing retry backlog and a stuck-open circuit breaker were all invisible: the receive side stays healthy in each case because it is. New internal/metrics registers, on the existing default registry that the go-http-metrics recorder and the promhttp handler already share: - webhooker_events_received_total - webhooker_delivery_attempts_total - webhooker_deliveries_succeeded_total - webhooker_deliveries_failed_total - webhooker_delivery_retries_total - webhooker_delivery_duration_seconds - webhooker_deliveries_pending / _retrying - webhooker_circuit_breakers_open The route mounting is untouched. Every delivery metric carries one label, target_type, whose domain is the four target-type constants; anything outside it collapses to "unknown" so no series can be minted from a UUID. Target ids, event ids and entrypoint ids are deliberately not labels. An attempt is counted, and its duration observed, only where one was actually dispatched — the target's own result path, which is also where the DeliveryResult is written. A delivery an open circuit breaker refuses sends nothing and records no result row; counting it would climb the attempts counter with no traffic behind it and pull the duration quantiles down for as long as the breaker stayed open, moving the metric the wrong way during the outage it exists to reveal. The log and database targets now time their own work, so their result rows carry a real duration too. The outcome counters move after the status row is written rather than before, so a transition the database rejected is never reported as an outcome that happened. The queue-depth gauges are counted out of the per-webhook databases by a 30s sampler rather than tracked as deltas, which would need seeding at startup and would drift on any transition that failed to persist. They publish an "unknown" series from registration: deliveries queued against a target that has since been deleted resolve to the empty type and are folded there, because a backlog behind a deleted target is precisely the one nobody is watching. The open-breaker gauge is recounted from the target's breaker registry on every state change. The orphaned-retry terminal path takes the target type as an argument rather than attaching the loaded target to the delivery. That path loads the delivery without its target relation on purpose: a populated Delivery.Target makes GORM's SaveBeforeAssociations upsert the whole target row on the status UPDATE, writing the plaintext target config — the credential, for a slack target — into the per-webhook events database. A test asserts that path leaves the targets table empty.
306 lines
8.0 KiB
Go
306 lines
8.0 KiB
Go
package delivery
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// databaseTarget is a no-retry target that archives the
|
|
// full inbound event into a per-webhook archive SQLite file,
|
|
// separate from the per-webhook event database. The event is
|
|
// already persisted in the per-webhook event DB by the time
|
|
// delivery runs; the database target additionally writes a
|
|
// durable long-term copy into archive-{webhookID}.db and then
|
|
// records a single attempt whose outcome reflects whether the
|
|
// archive write succeeded. See archiveWriter for the
|
|
// close/reopen, auto-recreate, and expiry semantics.
|
|
type databaseTarget struct {
|
|
eng *Engine
|
|
|
|
mu sync.Mutex
|
|
writers map[string]*archiveWriter
|
|
}
|
|
|
|
// Deliver implements Target. It archives the event, then
|
|
// records one successful attempt and marks the delivery
|
|
// delivered. An archiving error fails the delivery: the
|
|
// attempt is recorded as failed with the error and the
|
|
// delivery is marked failed, so a target that could not do
|
|
// its one job (archiving) never reports success. The target
|
|
// does not retry; the event remains durably stored in the
|
|
// per-webhook event database.
|
|
func (t *databaseTarget) Deliver(
|
|
_ context.Context,
|
|
webhookDB *gorm.DB,
|
|
d *database.Delivery,
|
|
_ *Task,
|
|
_ Scheduler,
|
|
) {
|
|
start := time.Now()
|
|
|
|
err := t.archive(d)
|
|
|
|
elapsed := time.Since(start)
|
|
|
|
t.eng.observeAttempt(d.Target.Type, elapsed)
|
|
|
|
if err != nil {
|
|
t.eng.log.Error(
|
|
"failed to archive event to database target",
|
|
"delivery_id", d.ID,
|
|
"event_id", d.EventID,
|
|
"error", err,
|
|
)
|
|
|
|
t.eng.recordResult(
|
|
webhookDB, d, 1, false, 0, "",
|
|
err.Error(), elapsed.Milliseconds(),
|
|
)
|
|
|
|
t.eng.updateDeliveryStatus(
|
|
webhookDB, d, d.Target.Type,
|
|
database.DeliveryStatusFailed,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
t.eng.recordResult(
|
|
webhookDB, d, 1, true, 0, "", "",
|
|
elapsed.Milliseconds(),
|
|
)
|
|
|
|
t.eng.updateDeliveryStatus(
|
|
webhookDB, d, d.Target.Type,
|
|
database.DeliveryStatusDelivered,
|
|
)
|
|
}
|
|
|
|
// archive writes the full event as a row into the webhook's
|
|
// archive database, honouring the optional per-target expiry
|
|
// parsed from the target config JSON.
|
|
func (t *databaseTarget) archive(d *database.Delivery) error {
|
|
webhookID := d.Event.WebhookID
|
|
if webhookID == "" {
|
|
return errArchiveMissingWebhookID
|
|
}
|
|
|
|
expiry, err := parseArchiveExpiry(d.Target.Config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
w, err := t.writerFor(webhookID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
row := archivedEvent{
|
|
EventID: d.Event.ID,
|
|
WebhookID: webhookID,
|
|
EntrypointID: d.Event.EntrypointID,
|
|
Method: d.Event.Method,
|
|
Headers: d.Event.Headers,
|
|
Body: d.Event.Body,
|
|
ContentType: d.Event.ContentType,
|
|
}
|
|
|
|
return w.write(row, expiry)
|
|
}
|
|
|
|
// writerFor returns the archiveWriter for a webhook, creating
|
|
// and caching it on first use. Each webhook has one writer so
|
|
// its close/reopen debounce state is shared across concurrent
|
|
// deliveries. The archive file lives beside the per-webhook
|
|
// event database in the data directory.
|
|
func (t *databaseTarget) writerFor(
|
|
webhookID string,
|
|
) (*archiveWriter, error) {
|
|
path, err := t.archivePath(webhookID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
if t.writers == nil {
|
|
t.writers = make(map[string]*archiveWriter)
|
|
}
|
|
|
|
w, ok := t.writers[webhookID]
|
|
if !ok {
|
|
w = newArchiveWriter(path, t.eng.log)
|
|
t.writers[webhookID] = w
|
|
}
|
|
|
|
// A delivery claims the entry: even if the idle sweep created
|
|
// it moments ago, it now belongs to the registry proper and
|
|
// the sweep must leave it in place when it finishes.
|
|
w.sweepOwned = false
|
|
|
|
return w, nil
|
|
}
|
|
|
|
// sweepWriterFor returns the archive writer the idle sweep should
|
|
// prune a webhook through, together with whether the sweep itself
|
|
// created the registry entry.
|
|
//
|
|
// The sweep must route its prune through the registered writer so
|
|
// the writer's mutex orders it against concurrent writes, but it
|
|
// must never leave a registry entry behind: a sweep that ran
|
|
// concurrently with the webhook's deletion would otherwise
|
|
// re-create an entry that nothing will ever evict again, which is
|
|
// exactly the leak eviction exists to prevent. An entry the sweep
|
|
// creates is therefore marked sweep-owned and handed back to
|
|
// releaseSweepWriter when the sweep is done.
|
|
func (t *databaseTarget) sweepWriterFor(
|
|
webhookID string,
|
|
) (*archiveWriter, bool, error) {
|
|
path, err := t.archivePath(webhookID)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
if t.writers == nil {
|
|
t.writers = make(map[string]*archiveWriter)
|
|
}
|
|
|
|
w, ok := t.writers[webhookID]
|
|
if ok {
|
|
return w, false, nil
|
|
}
|
|
|
|
w = newArchiveWriter(path, t.eng.log)
|
|
w.sweepOwned = true
|
|
t.writers[webhookID] = w
|
|
|
|
return w, true, nil
|
|
}
|
|
|
|
// releaseSweepWriter drops a registry entry that the idle sweep
|
|
// created, so a sweep leaves the registry exactly as it found it.
|
|
//
|
|
// The entry is removed only if it is still the very writer the
|
|
// sweep installed and no delivery has claimed it in the meantime
|
|
// (writerFor clears sweepOwned when it hands a writer to the
|
|
// write path). Both conditions are evaluated under the registry
|
|
// lock, so an eviction that raced the sweep — which removes the
|
|
// entry outright — simply finds nothing left to do here, and a
|
|
// delivery that adopted the writer keeps a registered, evictable
|
|
// one.
|
|
func (t *databaseTarget) releaseSweepWriter(
|
|
webhookID string, w *archiveWriter,
|
|
) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
cur, ok := t.writers[webhookID]
|
|
if !ok || cur != w || !cur.sweepOwned {
|
|
return
|
|
}
|
|
|
|
delete(t.writers, webhookID)
|
|
}
|
|
|
|
// archivePath returns the archive file path for a webhook: it
|
|
// lives beside the per-webhook event database in the data
|
|
// directory. It does not touch the filesystem.
|
|
func (t *databaseTarget) archivePath(
|
|
webhookID string,
|
|
) (string, error) {
|
|
if t.eng.dbManager == nil {
|
|
return "", errArchiveNoDataDir
|
|
}
|
|
|
|
dir := filepath.Dir(t.eng.dbManager.DBPath(webhookID))
|
|
|
|
return filepath.Join(
|
|
dir, fmt.Sprintf("archive-%s.db", webhookID),
|
|
), nil
|
|
}
|
|
|
|
// evict drops a webhook's archive writer from the registry and
|
|
// closes its handle, so a deleted webhook does not leave a
|
|
// writer (and an open archive handle within its debounce
|
|
// window) alive for the process lifetime.
|
|
//
|
|
// The map entry is removed under the registry lock, which is
|
|
// then released before the handle is closed under the writer's
|
|
// own lock: that ordering keeps the registry available to other
|
|
// webhooks while an in-flight write on this one drains, and
|
|
// closing under the writer's lock means eviction can never race
|
|
// a write.
|
|
//
|
|
// Eviction is idempotent and silent for a webhook with no
|
|
// writer, which is the common case: a webhook with no database
|
|
// target never creates one. It never deletes the archive file.
|
|
func (t *databaseTarget) evict(webhookID string) {
|
|
t.mu.Lock()
|
|
|
|
w, ok := t.writers[webhookID]
|
|
if ok {
|
|
delete(t.writers, webhookID)
|
|
}
|
|
|
|
t.mu.Unlock()
|
|
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
w.evict()
|
|
|
|
t.eng.log.Info(
|
|
"evicted archive writer",
|
|
"webhook_id", webhookID,
|
|
"path", w.path,
|
|
)
|
|
}
|
|
|
|
// sweepWebhook prunes one webhook's archive of rows older than
|
|
// expiry, without requiring a write. It returns nil (nothing to
|
|
// do) when the archive file does not exist, so a sweep never
|
|
// creates an archive for a webhook that has a database target
|
|
// but has never received an event.
|
|
//
|
|
// It also never leaves a registry entry behind: an entry it had
|
|
// to create to reach the writer's mutex is released again once
|
|
// the prune is done, so a sweep racing a webhook deletion cannot
|
|
// resurrect the writer the eviction just dropped.
|
|
func (t *databaseTarget) sweepWebhook(
|
|
webhookID string, expiry time.Duration,
|
|
) error {
|
|
path, err := t.archivePath(webhookID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check before taking a writer at all: a webhook whose
|
|
// archive has never been created gets no writer, no handle,
|
|
// and no file.
|
|
if !fileExists(path) {
|
|
return nil
|
|
}
|
|
|
|
w, created, err := t.sweepWriterFor(webhookID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if created {
|
|
defer t.releaseSweepWriter(webhookID, w)
|
|
}
|
|
|
|
return w.sweepExpired(expiry)
|
|
}
|