check / check (push) Successful in 3m19s
Each attempt shows when it was recorded and each delivery when it was created, in the event log and on the event's page. The event log's event times read as the recent events list does: how long ago, with the full UTC time on hover. A delivery records whether Replay created it, in a new replay column added to the delivery model in place. Such a delivery is labelled a replay in the event's summary line and in both pages' delivery lists. Model: opus-5-5
260 lines
7.3 KiB
Go
260 lines
7.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
)
|
|
|
|
// TargetRowView is one row of the target list on a webhook's page.
|
|
type TargetRowView struct {
|
|
delivery.TargetView
|
|
|
|
// Deliveries counts the target's delivered and failed deliveries,
|
|
// and is nil when the webhook's event database could not be read.
|
|
Deliveries *TargetDeliveries
|
|
|
|
// Archive is a database target's archive file, and nil for a target
|
|
// of any other type.
|
|
Archive *ArchiveFileView
|
|
|
|
// Paused is set while the target's circuit breaker is turning its
|
|
// deliveries away, and nil otherwise.
|
|
Paused *PausedView
|
|
}
|
|
|
|
// PausedView is a target's circuit breaker turning deliveries away.
|
|
// While the breaker is open, Until is a time in UTC, and Relative how
|
|
// long that is from now: on the target's row, when the cooldown ends;
|
|
// on a delivery, the earliest it can be tried next. While it is
|
|
// half-open both are empty: the cooldown has ended, and the target's
|
|
// deliveries are held while one delivery tests whether the target has
|
|
// recovered.
|
|
type PausedView struct {
|
|
Until string
|
|
Relative string
|
|
}
|
|
|
|
// pausedView reads the target's circuit breaker for its row, and
|
|
// returns nil when the breaker lets the target's deliveries through.
|
|
func (h *Handlers) pausedView(targetID string) *PausedView {
|
|
state, cooldown := h.breakers.StateAndCooldown(targetID)
|
|
|
|
switch {
|
|
case state == delivery.CircuitHalfOpen:
|
|
return &PausedView{}
|
|
case state == delivery.CircuitOpen && cooldown > 0:
|
|
return newPausedView(time.Now().Add(cooldown))
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// deliveryPausedView reads the circuit breaker of a retrying delivery's
|
|
// target. While it is open, it says the earliest the delivery can be
|
|
// tried next: the later of the cooldown's end and the end of the
|
|
// delivery's own backoff after its last attempt. It is only the
|
|
// earliest: when the cooldown ends, one of the target's waiting
|
|
// deliveries is sent to test it while the others wait at least one more
|
|
// cooldown. Otherwise it returns nil, half-open included, since the
|
|
// delivery may then be the one being sent to test the target.
|
|
func (h *Handlers) deliveryPausedView(
|
|
targetID string, attempts []deliveryResultRow,
|
|
) *PausedView {
|
|
state, cooldown := h.breakers.StateAndCooldown(targetID)
|
|
if state != delivery.CircuitOpen || cooldown <= 0 {
|
|
return nil
|
|
}
|
|
|
|
next := time.Now().Add(cooldown)
|
|
|
|
if len(attempts) > 0 {
|
|
last := attempts[len(attempts)-1]
|
|
|
|
backoffEnd := last.CreatedAt.Add(delivery.Backoff(last.AttemptNum))
|
|
if backoffEnd.After(next) {
|
|
next = backoffEnd
|
|
}
|
|
}
|
|
|
|
return newPausedView(next)
|
|
}
|
|
|
|
// newPausedView is a PausedView of deliveries paused until the given
|
|
// time. A time not on the current UTC day is written with its date.
|
|
func newPausedView(until time.Time) *PausedView {
|
|
until = until.UTC()
|
|
|
|
layout := time.TimeOnly
|
|
if until.Format(time.DateOnly) != time.Now().UTC().Format(time.DateOnly) {
|
|
layout = time.DateTime
|
|
}
|
|
|
|
return &PausedView{
|
|
Until: until.Format(layout) + " UTC",
|
|
Relative: humanize.Time(until),
|
|
}
|
|
}
|
|
|
|
// TargetDeliveries is how many of a target's deliveries became
|
|
// delivered and how many failed: in total, which retention does not
|
|
// reduce, and in the last 24 hours. Deliveries still pending or
|
|
// retrying count in neither.
|
|
type TargetDeliveries struct {
|
|
Delivered int64
|
|
Failed int64
|
|
|
|
DeliveredLast24Hours int64
|
|
FailedLast24Hours int64
|
|
}
|
|
|
|
// ArchiveFileView is what a database target's row shows about its
|
|
// archive file.
|
|
type ArchiveFileView struct {
|
|
Name string
|
|
|
|
// Note stands in for the size and the last write when there are
|
|
// none to show, and is empty when there are.
|
|
Note string
|
|
|
|
// Size is the size on disk. Written is how long ago the file was
|
|
// last written, and WrittenUTC the full time the page shows on
|
|
// hover.
|
|
Size string
|
|
Written string
|
|
WrittenUTC string
|
|
}
|
|
|
|
// targetRows projects a webhook's targets for the target list on its
|
|
// page.
|
|
func (h *Handlers) targetRows(
|
|
webhook *database.Webhook, targets []database.Target,
|
|
) []TargetRowView {
|
|
views := delivery.NewTargetViews(targets)
|
|
rows := make([]TargetRowView, len(views))
|
|
|
|
deliveries, err := h.loadTargetDeliveries(webhook.ID)
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to read target delivery counts",
|
|
"webhook_id", webhook.ID,
|
|
"error", err,
|
|
)
|
|
}
|
|
|
|
// NewTargetViews returns one view per target, in order.
|
|
for i := range views {
|
|
rows[i].TargetView = views[i]
|
|
|
|
if err == nil {
|
|
counts := deliveries[targets[i].ID]
|
|
rows[i].Deliveries = &counts
|
|
}
|
|
|
|
if targets[i].Type == database.TargetTypeDatabase {
|
|
rows[i].Archive = h.archiveFileView(webhook, &targets[i])
|
|
}
|
|
|
|
rows[i].Paused = h.pausedView(targets[i].ID)
|
|
}
|
|
|
|
return rows
|
|
}
|
|
|
|
// loadTargetDeliveries reads the delivery counts of a webhook's targets
|
|
// from its event database, keyed by target. A target with no deliveries
|
|
// is left out, and so is every target when the event database does not
|
|
// exist yet, since opening it would create it.
|
|
func (h *Handlers) loadTargetDeliveries(
|
|
webhookID string,
|
|
) (map[string]TargetDeliveries, error) {
|
|
if !h.dbMgr.DBExists(webhookID) {
|
|
return map[string]TargetDeliveries{}, nil
|
|
}
|
|
|
|
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return readTargetDeliveries(webhookDB, time.Now())
|
|
}
|
|
|
|
// readTargetDeliveries counts each target's deliveries that became
|
|
// delivered and those that failed: in total from the targets' running
|
|
// totals, and in the 24 hours before now from the deliveries' status
|
|
// index. Each is one query for all the targets, and neither reads every
|
|
// stored delivery.
|
|
func readTargetDeliveries(
|
|
db *gorm.DB, now time.Time,
|
|
) (map[string]TargetDeliveries, error) {
|
|
var totals []database.TargetTotals
|
|
|
|
err := db.Find(&totals).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading target totals: %w", err)
|
|
}
|
|
|
|
lastDay, err := finishedByTarget(db, now.Add(-longWindow))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
byTarget := make(map[string]TargetDeliveries, len(totals))
|
|
|
|
for _, total := range totals {
|
|
byTarget[total.TargetID] = TargetDeliveries{
|
|
Delivered: total.Delivered,
|
|
Failed: total.Failed,
|
|
}
|
|
}
|
|
|
|
for _, finished := range lastDay {
|
|
counts := byTarget[finished.TargetID]
|
|
counts.DeliveredLast24Hours = finished.Delivered
|
|
counts.FailedLast24Hours = finished.Failed
|
|
byTarget[finished.TargetID] = counts
|
|
}
|
|
|
|
return byTarget, nil
|
|
}
|
|
|
|
// archiveFileView describes a database target's archive file from the
|
|
// file's metadata alone; the archive is never opened. The file is found
|
|
// by the name the archive writer uses, so it follows a rename of the
|
|
// webhook or the target.
|
|
func (h *Handlers) archiveFileView(
|
|
webhook *database.Webhook, target *database.Target,
|
|
) *ArchiveFileView {
|
|
path := delivery.ArchivePath(h.dbMgr, webhook, target)
|
|
view := &ArchiveFileView{Name: filepath.Base(path)}
|
|
|
|
file, err := delivery.StatArchive(path)
|
|
|
|
switch {
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
view.Note = "not created yet"
|
|
case err != nil:
|
|
h.log.Error(
|
|
"failed to read archive file metadata",
|
|
"target_id", target.ID,
|
|
"error", err,
|
|
)
|
|
|
|
view.Note = "could not be read"
|
|
default:
|
|
view.Size = humanize.Bytes(uint64(file.Size)) //nolint:gosec // never negative
|
|
view.Written = humanize.Time(file.Written)
|
|
view.WrittenUTC = file.Written.UTC().Format(time.DateTime) + " UTC"
|
|
}
|
|
|
|
return view
|
|
}
|