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

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.

A change of webhook or target name renames its archive files under the
archive writer's lock, before the new name is saved, and back again if
the save fails. A rename never replaces a file, and one that fails part
way moves back what it moved. 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 08:33:51 +00:00
committed by sneak
parent 5b1d283d06
commit 444ecb401c
21 changed files with 1647 additions and 663 deletions
+91 -44
View File
@@ -550,6 +550,7 @@ func (h *Handlers) applyWebhookEdit(
return
}
oldName := webhook.Name
webhook.Name = name
webhook.Description = r.PostFormValue("description")
@@ -572,8 +573,41 @@ func (h *Handlers) applyWebhookEdit(
webhook.RetentionDays = retentionDays
err := h.db.DB().Save(webhook).Error
// A new name renames the archive files before it is saved (see
// delivery.Engine.Rename). If either step fails, they go back to
// the name that is still stored.
err := h.renameWebhookArchives(webhook.ID, oldName, webhook.Name)
if err == nil {
err = h.db.DB().Save(webhook).Error
}
if err != nil {
restoreErr := h.renameWebhookArchives(
webhook.ID, webhook.Name, oldName,
)
if restoreErr != nil {
h.log.Error(
"failed to rename archives back",
"webhook_id", webhook.ID,
"error", restoreErr,
)
}
if errors.Is(err, delivery.ErrArchiveNameTaken) {
data := map[string]any{
tmplKeyWebhook: webhook,
tmplKeyError: "Not saved: " + err.Error() +
". Move that archive out of the data directory, " +
"its .db together with any -wal and -shm beside " +
"it, then save again.",
}
w.WriteHeader(http.StatusConflict)
h.renderTemplate(w, r, "source_edit.html", data)
return
}
h.serverError(w, r, "failed to update webhook", err)
return
@@ -711,11 +745,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
@@ -723,50 +757,62 @@ 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 from the webhook name oldName to newName,
// keeping each target's own name. It does nothing when the name is
// unchanged. It tries every target even after one fails, so that
// moving the archives back after a failed edit leaves none under the
// new name, and returns every failure joined.
func (h *Handlers) renameWebhookArchives(
webhookID, oldName, newName string,
) error {
if h.archives == nil || oldName == newName {
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
}
var errs []error
for i := range targets {
err = h.archives.Rename(
targets[i].ID, newName, targets[i].Name,
)
return
if err != nil {
errs = append(errs, err)
}
}
if remaining > 0 {
return
}
h.evictArchiveWriter(webhookID)
return errors.Join(errs...)
}
// ownedWebhook resolves the request's sourceID parameter to a
@@ -1638,29 +1684,28 @@ 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,
targetDeleted,
)
}
// 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, which carries done as
// optional afterDelete hook runs with the child's id once the
// delete has removed it, before the redirect, which carries done as
// its notice.
func (h *Handlers) deleteChildResource(
idParam string,
model any,
errMsg string,
afterDelete func(webhookID string),
afterDelete func(childID string),
done noticeCode,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -1697,8 +1742,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(