Name each database target's archive for its webhook and target (closes #376)
check / check (push) Successful in 3m14s

Each database target now has its own archive file,
archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, instead of one
archive-WEBHOOKID.db per webhook. delivery.ArchiveFileName builds the
name: each name is lowercased, keeps ASCII letters and digits, turns
every other run of characters into one dash, and is cut to 40
characters.

Renaming a webhook or a target renames its archive files under the
archive writer's lock, before the new name is saved, and back again if
the save fails. Deleting a target evicts only that target's writer.
Archive files are never deleted, and nothing looks for files under the
old name.

Model: opus-5-5
This commit is contained in:
2026-10-02 05:10:15 +00:00
parent 7d360babed
commit d88948a1c3
21 changed files with 1277 additions and 663 deletions
+69 -44
View File
@@ -559,6 +559,7 @@ func (h *Handlers) applyWebhookEdit(
return
}
oldName := webhook.Name
webhook.Name = name
webhook.Description = r.PostFormValue("description")
@@ -581,8 +582,24 @@ func (h *Handlers) applyWebhookEdit(
webhook.RetentionDays = retentionDays
err := h.db.DB().Save(webhook).Error
// The archive files are renamed before the new name is saved
// (see delivery.Engine.RenameArchive). If either step fails,
// they go back to the name that is still stored.
err := h.renameWebhookArchives(webhook.ID, webhook.Name)
if err == nil {
err = h.db.DB().Save(webhook).Error
}
if err != nil {
restoreErr := h.renameWebhookArchives(webhook.ID, oldName)
if restoreErr != nil {
h.log.Error(
"failed to rename archives back",
"webhook_id", webhook.ID,
"error", restoreErr,
)
}
h.serverError(w, "failed to update webhook", err)
return
@@ -717,11 +734,11 @@ func (h *Handlers) commitWebhookDeletion(
return tx.Commit().Error
}
// evictArchiveWriter asks the delivery engine to drop its
// cached archive writer for a webhook, closing the archive file
// handle.
// evictArchiveWriter asks the delivery engine to drop the cached
// archive writers of a webhook's database targets, closing their
// archive file handles.
//
// The archive database file is NOT deleted. Unlike the event
// The archive database files are NOT deleted. Unlike the event
// database — which is per-webhook working storage and is
// hard-deleted with the webhook — an archive is explicitly
// long-term storage that an operator may want to keep or move
@@ -729,50 +746,57 @@ func (h *Handlers) commitWebhookDeletion(
// deleting a webhook would be a surprising and unrecoverable
// data loss, so the file is left for the operator to handle.
func (h *Handlers) evictArchiveWriter(webhookID string) {
if h.evictor == nil {
if h.archives == nil {
return
}
h.evictor.EvictWebhook(webhookID)
h.archives.EvictWebhook(webhookID)
}
// evictArchiveWriterIfUnused releases a webhook's archive
// writer once the webhook has no database target left to feed
// it.
//
// It is called after any child resource of a webhook is
// deleted, and is correct without knowing which kind was: it
// evicts only when no database target remains, so deleting one
// of several database targets — or deleting an unrelated
// target type — leaves a still-needed writer alone. When no
// database target ever existed there is no writer and eviction
// is a no-op. Soft-deleted targets are excluded by GORM's
// default scope, so the row just deleted is not counted.
func (h *Handlers) evictArchiveWriterIfUnused(webhookID string) {
var remaining int64
// evictTargetArchiveWriter is evictArchiveWriter for one deleted
// target, and leaves its archive file on disk for the same reason.
// A target that is not a database target has no writer, and
// evicting it does nothing.
func (h *Handlers) evictTargetArchiveWriter(targetID string) {
if h.archives == nil {
return
}
h.archives.EvictTarget(targetID)
}
// renameWebhookArchives renames the archive file of every database
// target of a webhook for the webhook name webhookName, keeping
// each target's own name. It stops at the first failure.
func (h *Handlers) renameWebhookArchives(
webhookID, webhookName string,
) error {
if h.archives == nil {
return nil
}
var targets []database.Target
err := h.db.DB().
Model(&database.Target{}).
Where(
"webhook_id = ? AND type = ?",
webhookID, database.TargetTypeDatabase,
).
Count(&remaining).Error
Find(&targets).Error
if err != nil {
h.log.Error(
"failed to count remaining database targets",
"webhook_id", webhookID,
"error", err,
return err
}
for i := range targets {
err = h.archives.RenameArchive(
targets[i].ID, webhookName, targets[i].Name,
)
return
if err != nil {
return err
}
}
if remaining > 0 {
return
}
h.evictArchiveWriter(webhookID)
return nil
}
// ownedWebhook resolves the request's sourceID parameter to a
@@ -1649,27 +1673,26 @@ func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
)
}
// HandleTargetDelete handles deleting a target. Deleting the
// last database target of a webhook leaves its archive writer
// with nothing to write, so the writer is evicted and its
// handle closed; the archive file is left on disk.
// HandleTargetDelete handles deleting a target. A deleted
// database target's archive writer is evicted and its handle
// closed; the archive file is left on disk.
func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
return h.deleteChildResource(
"targetID", &database.Target{},
"failed to delete target",
h.evictArchiveWriterIfUnused,
h.evictTargetArchiveWriter,
)
}
// deleteChildResource returns a handler that deletes a child
// resource (entrypoint or target) belonging to a webhook. The
// optional afterDelete hook runs with the webhook's id once the
// delete has succeeded, before the redirect.
// optional afterDelete hook runs with the child's id once the
// delete has removed it, before the redirect.
func (h *Handlers) deleteChildResource(
idParam string,
model any,
errMsg string,
afterDelete func(webhookID string),
afterDelete func(childID string),
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
@@ -1709,8 +1732,10 @@ func (h *Handlers) deleteChildResource(
return
}
if afterDelete != nil {
afterDelete(webhook.ID)
// Only for a row this webhook really had: the id came from
// the URL and may name another webhook's child.
if afterDelete != nil && result.RowsAffected > 0 {
afterDelete(childID)
}
http.Redirect(