Rotate a database target's archive monthly, daily or hourly (closes #379)
check / check (push) Successful in 3m28s

A database target's rotation (none, monthly, daily or hourly) puts the
UTC period of each event's receive time in its archive file name, so
each file holds exactly its period's events. It is on the new webhook
page, the add target form and the target edit form, and shown in the
target list.

Renames move every one of a target's files and move them back if one
fails. The sweep prunes every file, one at a time under the target's
lock, and deletes a rotated file it leaves empty. Download lists the
files, then opens one at a time, oldest first, finding each again under
the target's current names, and gives each row its period. The target
list names the current file and totals the size of all of them.

Model: opus-5-5
This commit is contained in:
2026-10-03 02:06:19 +00:00
parent ea8384f4a2
commit ed344ecc66
36 changed files with 2125 additions and 406 deletions
+40 -14
View File
@@ -27,13 +27,11 @@ func (h *Handlers) HandleTargetDownload() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithoutCancel(r.Context())
webhook, target, export, ok := h.openTargetArchive(ctx, w, r)
webhook, target, export, ok := h.listTargetArchive(w, r)
if !ok {
return
}
defer func() { _ = export.Close() }()
now := time.Now()
w.Header().Set("Content-Type", "application/gzip")
@@ -81,16 +79,15 @@ func (d downloadWriter) Write(b []byte) (int, error) {
return d.w.Write(b)
}
// openTargetArchive opens the archive of the request's database target
// for export, with its reads under ctx. It reports false once it has
// written the response.
// listTargetArchive lists the archive files of the request's database
// target for export. It reports false once it has written the response.
//
// It holds renameMu, which every archive rename runs under, while it
// reads the stored names and opens the file, so the file it opens is
// the one those names give. It lets go before the export is streamed:
// once the file is open, a rename does not affect the export.
func (h *Handlers) openTargetArchive(
ctx context.Context,
// reads the stored names and lists the files, so the files it lists
// are the ones those names give. It lets go before the export is
// streamed, which takes renameMu again for each file only while it
// finds the file under the names stored then and opens it.
func (h *Handlers) listTargetArchive(
w http.ResponseWriter,
r *http.Request,
) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) {
@@ -108,14 +105,43 @@ func (h *Handlers) openTargetArchive(
return database.Webhook{}, nil, nil, false
}
export, err := delivery.OpenArchiveExport(
ctx, delivery.ArchivePath(h.dbMgr, &webhook, target), h.log,
export, err := delivery.NewArchiveExport(
delivery.ArchivePath(h.dbMgr, &webhook, target),
&h.renameMu,
func() (string, error) {
return h.storedArchivePath(webhook.ID, target.ID)
},
h.log,
)
if err != nil {
h.serverError(w, r, "failed to open archive for export", err)
h.serverError(w, r, "failed to list archive for export", err)
return database.Webhook{}, nil, nil, false
}
return webhook, target, export, true
}
// storedArchivePath returns the path delivery.ArchivePath gives a
// database target under the names stored for it and its webhook now.
// Its caller holds renameMu. A webhook or target deleted since is still
// found, since deleting one leaves its archive files under their names.
func (h *Handlers) storedArchivePath(
webhookID, targetID string,
) (string, error) {
var webhook database.Webhook
err := h.db.DB().Unscoped().First(&webhook, "id = ?", webhookID).Error
if err != nil {
return "", err
}
var target database.Target
err = h.db.DB().Unscoped().First(&target, "id = ?", targetID).Error
if err != nil {
return "", err
}
return delivery.ArchivePath(h.dbMgr, &webhook, &target), nil
}