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

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, the sweep prunes every file
and deletes a rotated file it leaves empty, Download exports every file
oldest first with each row's period, and 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-02 23:51:42 +00:00
parent 22fa502638
commit 13f881007a
33 changed files with 1753 additions and 328 deletions
+54 -25
View File
@@ -296,9 +296,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
@@ -315,6 +316,9 @@ func newSourceFormData(
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(
in.ArchiveExpiry,
),
tmplKeyArchiveRotationChoices: archiveRotationOptions(
in.ArchiveRotation,
),
}
}
@@ -341,12 +345,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) {
@@ -417,9 +422,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,
})
}
@@ -626,9 +632,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
@@ -1789,6 +1796,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
@@ -1812,6 +1821,7 @@ func targetFormInputFrom(r *http.Request) targetFormInput {
Timeout: r.PostFormValue("timeout"),
MaxRetries: r.PostFormValue("max_retries"),
Expiry: r.PostFormValue("expiry"),
Rotation: r.PostFormValue("rotation"),
}
}
@@ -1832,7 +1842,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:
@@ -1953,22 +1963,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
}