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

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 00:47:31 +00:00
parent bcdd4791ec
commit e55134532c
36 changed files with 2120 additions and 406 deletions
+54 -25
View File
@@ -300,9 +300,10 @@ type sourceFormInput struct {
// destination.
HTTPURL string
// Archive asks for a database (archive) target, whose rows expire
// after ArchiveExpiry.
Archive bool
ArchiveExpiry string
// after ArchiveExpiry and whose files rotate by ArchiveRotation.
Archive bool
ArchiveExpiry string
ArchiveRotation string
}
// newSourceFormData builds the template data for the webhook creation
@@ -319,6 +320,9 @@ func newSourceFormData(
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(
in.ArchiveExpiry,
),
tmplKeyArchiveRotationChoices: archiveRotationOptions(
in.ArchiveRotation,
),
}
}
@@ -345,12 +349,13 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
}
in := sourceFormInput{
Name: r.PostFormValue("name"),
Description: r.PostFormValue("description"),
RetentionDays: r.PostFormValue("retention_days"),
HTTPURL: r.PostFormValue("http_url"),
Archive: r.PostFormValue("archive") != "",
ArchiveExpiry: r.PostFormValue("archive_expiry"),
Name: r.PostFormValue("name"),
Description: r.PostFormValue("description"),
RetentionDays: r.PostFormValue("retention_days"),
HTTPURL: r.PostFormValue("http_url"),
Archive: r.PostFormValue("archive") != "",
ArchiveExpiry: r.PostFormValue("archive_expiry"),
ArchiveRotation: r.PostFormValue("archive_rotation"),
}
refuse := func(errMsg string) {
@@ -421,9 +426,10 @@ func (h *Handlers) newWebhookTargets(
if in.Archive {
requested = append(requested, targetFormInput{
Name: "Archive",
Type: database.TargetTypeDatabase,
Expiry: in.ArchiveExpiry,
Name: "Archive",
Type: database.TargetTypeDatabase,
Expiry: in.ArchiveExpiry,
Rotation: in.ArchiveRotation,
})
}
@@ -630,9 +636,10 @@ func (h *Handlers) renderSourceDetail(
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
tmplKeyTargetForm: targetForm,
"TargetError": targetErr,
// The add target form's select starts on its expiry
// through Alpine, so no choice is selected here.
tmplKeyArchiveExpiryChoices: archiveExpiryChoices(),
// The add target form's selects start on its expiry and
// rotation through Alpine, so no choice is selected here.
tmplKeyArchiveExpiryChoices: archiveExpiryChoices(),
tmplKeyArchiveRotationChoices: archiveRotationChoices(),
}
status := http.StatusOK
@@ -1800,6 +1807,8 @@ type targetFormInput struct {
MaxRetries string
// Expiry is a database (archive) target's row expiry.
Expiry string
// Rotation is a database (archive) target's rotation.
Rotation string
}
// targetFormInputFrom reads a target form from a request body. The
@@ -1823,6 +1832,7 @@ func targetFormInputFrom(r *http.Request) targetFormInput {
Timeout: r.PostFormValue("timeout"),
MaxRetries: r.PostFormValue("max_retries"),
Expiry: r.PostFormValue("expiry"),
Rotation: r.PostFormValue("rotation"),
}
}
@@ -1843,7 +1853,7 @@ func (h *Handlers) buildTargetConfig(
case database.TargetTypeSlack:
return h.buildSlackTargetConfig(ctx, in.URL)
case database.TargetTypeDatabase:
return buildDatabaseTargetConfig(in.Expiry)
return buildDatabaseTargetConfig(in.Expiry, in.Rotation)
case database.TargetTypeLog:
return "", "", nil
default:
@@ -1964,22 +1974,41 @@ func marshalTargetConfig(cfg any) (string, error) {
}
// buildDatabaseTargetConfig builds config JSON for a database
// (archive) target. The optional expiry is validated here, at
// creation time, so an unparseable value is refused instead of
// failing every subsequent delivery. An empty expiry yields an
// empty config (the keep-forever default).
func buildDatabaseTargetConfig(expiry string) (string, string, error) {
// (archive) target. The optional expiry and rotation are validated
// here, at creation time, so a bad value is refused instead of
// failing every subsequent delivery. Each is stored only when set,
// and with neither the config is empty (the keep-forever, one-file
// default).
func buildDatabaseTargetConfig(
expiry, rotation string,
) (string, string, error) {
expiry = strings.TrimSpace(expiry)
if expiry == "" {
return "", "", nil
}
err := delivery.ValidateArchiveExpiry(expiry)
if err != nil {
return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
}
configJSON, err := marshalTargetConfig(map[string]any{"expiry": expiry})
err = delivery.ValidateArchiveRotation(rotation)
if err != nil {
return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil
}
cfg := map[string]any{}
if expiry != "" {
cfg["expiry"] = expiry
}
if rotation != "" {
cfg["rotation"] = rotation
}
if len(cfg) == 0 {
return "", "", nil
}
configJSON, err := marshalTargetConfig(cfg)
return configJSON, "", err
}