check / check (push) Waiting to run
The SQLite archive files the database target writes are now managed by internal/archive: file names, rotation, the writer of each target, pruning, renames, the gzipped JSON export, the idle sweep, and the rename lock, which was Handlers.renameMu. The database target stays in internal/delivery and writes through archive.Writers; the engine still closes the writers when it stops, so the stop order is unchanged. The handlers take the rename lock from archive.Archives.RenameLock, and rename and evict through archive.Archives. Exported names drop their Archive prefix in the new package. The tests moved with the code; the two that check a delivery's recorded attempt stay in delivery. Model: opus-5-5
199 lines
5.0 KiB
Go
199 lines
5.0 KiB
Go
package archive
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// The archive rotations: how often a database target starts a new
|
|
// archive file. Every rotation but none puts the period of an event's
|
|
// receive time, in UTC, in the name of the file the event goes to,
|
|
// written in the layout beside it.
|
|
const (
|
|
archiveRotationNone = "none"
|
|
archiveRotationMonthly = "monthly"
|
|
archiveRotationDaily = "daily"
|
|
archiveRotationHourly = "hourly"
|
|
|
|
archiveMonthLayout = "2006-01"
|
|
archiveDayLayout = "2006-01-02"
|
|
archiveHourLayout = "2006-01-02-15"
|
|
)
|
|
|
|
// errArchiveRotationUnknown is returned for a rotation that is not one
|
|
// of the four.
|
|
var errArchiveRotationUnknown = errors.New(
|
|
"rotation must be none, monthly, daily or hourly",
|
|
)
|
|
|
|
// archiveFile is one of a database target's archive files, and the
|
|
// period in its name: "" for the file named without one.
|
|
type archiveFile struct {
|
|
path string
|
|
period string
|
|
}
|
|
|
|
// ValidateRotation checks a user-supplied archive rotation for
|
|
// a database target: empty or none (both meaning one file), monthly,
|
|
// daily or hourly.
|
|
func ValidateRotation(rotation string) error {
|
|
switch rotation {
|
|
case "", archiveRotationNone, archiveRotationMonthly,
|
|
archiveRotationDaily, archiveRotationHourly:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("%w: %q", errArchiveRotationUnknown, rotation)
|
|
}
|
|
}
|
|
|
|
// ParseRotation reads the rotation from a database target's
|
|
// config JSON. An empty config or an empty rotation is none.
|
|
func ParseRotation(configJSON string) (string, error) {
|
|
if configJSON == "" {
|
|
return archiveRotationNone, nil
|
|
}
|
|
|
|
var cfg DatabaseTargetConfig
|
|
|
|
err := json.Unmarshal([]byte(configJSON), &cfg)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parsing database target config: %w", err)
|
|
}
|
|
|
|
err = ValidateRotation(cfg.Rotation)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if cfg.Rotation == "" {
|
|
return archiveRotationNone, nil
|
|
}
|
|
|
|
return cfg.Rotation, nil
|
|
}
|
|
|
|
// archivePeriod returns the period, in UTC, that a rotation puts an
|
|
// event received at receivedAt in: "2026-10" for monthly,
|
|
// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none.
|
|
func archivePeriod(rotation string, receivedAt time.Time) string {
|
|
switch rotation {
|
|
case archiveRotationMonthly:
|
|
return receivedAt.UTC().Format(archiveMonthLayout)
|
|
case archiveRotationDaily:
|
|
return receivedAt.UTC().Format(archiveDayLayout)
|
|
case archiveRotationHourly:
|
|
return receivedAt.UTC().Format(archiveHourLayout)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// archivePeriodPath returns the path of a database target's archive
|
|
// file for a period: path, as Path gives it, with "-" and the
|
|
// period put before its ".db". The period "" gives path itself.
|
|
func archivePeriodPath(path, period string) string {
|
|
if period == "" {
|
|
return path
|
|
}
|
|
|
|
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
|
|
}
|
|
|
|
// PathAt returns the archive file a database target writes an
|
|
// event received at receivedAt to: Path's file, with the
|
|
// period in its name when the target rotates.
|
|
func PathAt(
|
|
dbMgr *database.WebhookDBManager,
|
|
webhook *database.Webhook,
|
|
target *database.Target,
|
|
receivedAt time.Time,
|
|
) (string, error) {
|
|
rotation, err := ParseRotation(target.Config)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return archivePeriodPath(
|
|
Path(dbMgr, webhook, target),
|
|
archivePeriod(rotation, receivedAt),
|
|
), nil
|
|
}
|
|
|
|
// archiveFiles lists the database target's archive files that exist,
|
|
// given the path Path gives it: the file at path, then each
|
|
// file archivePeriodPath names for path and a period, oldest period
|
|
// first. Which rotation wrote a file does not matter, so the files of
|
|
// an earlier rotation setting are listed too.
|
|
func archiveFiles(path string) ([]archiveFile, error) {
|
|
dir := filepath.Dir(path)
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing archive files: %w", err)
|
|
}
|
|
|
|
stem := strings.TrimSuffix(filepath.Base(path), ".db")
|
|
|
|
var files []archiveFile
|
|
|
|
for _, entry := range entries {
|
|
period, ok := archiveFilePeriod(stem, entry.Name())
|
|
if ok {
|
|
files = append(files, archiveFile{
|
|
path: filepath.Join(dir, entry.Name()),
|
|
period: period,
|
|
})
|
|
}
|
|
}
|
|
|
|
// A month sorts before the days and hours in it.
|
|
slices.SortFunc(files, func(a, b archiveFile) int {
|
|
return strings.Compare(a.period, b.period)
|
|
})
|
|
|
|
return files, nil
|
|
}
|
|
|
|
// archiveFilePeriod reports whether name is the name of an archive
|
|
// file of the target whose file name without a period is stem+".db",
|
|
// and the period in it.
|
|
func archiveFilePeriod(stem, name string) (string, bool) {
|
|
rest, ok := strings.CutPrefix(name, stem)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
rest, ok = strings.CutSuffix(rest, ".db")
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
if rest == "" {
|
|
return "", true
|
|
}
|
|
|
|
period, ok := strings.CutPrefix(rest, "-")
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
for _, layout := range []string{
|
|
archiveMonthLayout, archiveDayLayout, archiveHourLayout,
|
|
} {
|
|
_, err := time.Parse(layout, period)
|
|
if err == nil {
|
|
return period, true
|
|
}
|
|
}
|
|
|
|
return "", false
|
|
}
|