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, ) { err := t.archive(d) 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(), 0, ) t.eng.updateDeliveryStatus( webhookDB, d, database.DeliveryStatusFailed, ) return } t.eng.recordResult( webhookDB, d, 1, true, 0, "", "", 0, ) t.eng.updateDeliveryStatus( webhookDB, d, 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 } return w, nil } // 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. 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, err := t.writerFor(webhookID) if err != nil { return err } return w.sweepExpired(expiry) }