Add send-log retention days to panel Settings.

Global administrators can set delivery journal retention (7-365 days) in SQLite;
SEND_LOG_RETENTION_DAYS seeds the initial default and the log-tailer re-reads
the value every prune cycle without a restart.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 21:57:48 +03:00
parent d0bf4a4226
commit 909145e5c8
20 changed files with 575 additions and 102 deletions
+71
View File
@@ -4,8 +4,79 @@ import (
"database/sql"
"errors"
"fmt"
"strconv"
)
const (
// SendLogRetentionDaysKey is the settings table key for delivery-journal
// retention (architecture.md § Persistence).
SendLogRetentionDaysKey = "send_log_retention_days"
SendLogRetentionDaysMin = 7
SendLogRetentionDaysMax = 365
// SendLogRetentionDaysDefault matches SEND_LOG_RETENTION_DAYS when unset.
SendLogRetentionDaysDefault = 90
)
// ErrSendLogRetentionDaysOutOfRange is returned when retention is outside the
// allowed panel range.
var ErrSendLogRetentionDaysOutOfRange = errors.New("send log retention days out of range")
// ValidateSendLogRetentionDays checks the panel-allowed retention window.
func ValidateSendLogRetentionDays(days int) error {
if days < SendLogRetentionDaysMin || days > SendLogRetentionDaysMax {
return fmt.Errorf("%w: must be between %d and %d days", ErrSendLogRetentionDaysOutOfRange, SendLogRetentionDaysMin, SendLogRetentionDaysMax)
}
return nil
}
func sendLogRetentionFallback(envDefault int) int {
if envDefault > 0 {
if err := ValidateSendLogRetentionDays(envDefault); err == nil {
return envDefault
}
}
return SendLogRetentionDaysDefault
}
// GetSendLogRetentionDays returns the effective retention window. When the
// setting is missing or invalid, envDefault is used (guide § Environment
// variables: SEND_LOG_RETENTION_DAYS).
func (s *Store) GetSendLogRetentionDays(envDefault int) (int, error) {
raw, err := s.GetSetting(SendLogRetentionDaysKey)
if err != nil {
return 0, err
}
if raw == "" {
return sendLogRetentionFallback(envDefault), nil
}
days, err := strconv.Atoi(raw)
if err != nil || ValidateSendLogRetentionDays(days) != nil {
return sendLogRetentionFallback(envDefault), nil
}
return days, nil
}
// SetSendLogRetentionDays persists the panel-configured retention window.
func (s *Store) SetSendLogRetentionDays(days int) error {
if err := ValidateSendLogRetentionDays(days); err != nil {
return err
}
return s.SetSetting(SendLogRetentionDaysKey, strconv.Itoa(days))
}
// EnsureSendLogRetentionDays seeds the setting from envDefault when it has
// never been written (first panel start after upgrade).
func (s *Store) EnsureSendLogRetentionDays(envDefault int) error {
raw, err := s.GetSetting(SendLogRetentionDaysKey)
if err != nil {
return err
}
if raw != "" {
return nil
}
return s.SetSendLogRetentionDays(sendLogRetentionFallback(envDefault))
}
// GetSetting returns a settings value or empty string when missing.
func (s *Store) GetSetting(key string) (string, error) {
var value string
+87
View File
@@ -0,0 +1,87 @@
package store
import (
"errors"
"testing"
)
func TestSendLogRetentionDaysSaveLoad(t *testing.T) {
st := openTestStore(t)
if err := st.SetSendLogRetentionDays(45); err != nil {
t.Fatalf("SetSendLogRetentionDays: %v", err)
}
got, err := st.GetSendLogRetentionDays(90)
if err != nil {
t.Fatalf("GetSendLogRetentionDays: %v", err)
}
if got != 45 {
t.Fatalf("got %d, want 45", got)
}
}
func TestSendLogRetentionDaysRejectsOutOfRange(t *testing.T) {
st := openTestStore(t)
for _, days := range []int{0, 6, 366, -1} {
if err := st.SetSendLogRetentionDays(days); err == nil {
t.Fatalf("SetSendLogRetentionDays(%d) want error", days)
} else if !errors.Is(err, ErrSendLogRetentionDaysOutOfRange) {
t.Fatalf("SetSendLogRetentionDays(%d) = %v, want ErrSendLogRetentionDaysOutOfRange", days, err)
}
}
}
func TestSendLogRetentionDaysBootstrapFromEnv(t *testing.T) {
st := openTestStore(t)
if err := st.EnsureSendLogRetentionDays(120); err != nil {
t.Fatalf("EnsureSendLogRetentionDays: %v", err)
}
got, err := st.GetSendLogRetentionDays(90)
if err != nil {
t.Fatalf("GetSendLogRetentionDays: %v", err)
}
if got != 120 {
t.Fatalf("got %d, want 120", got)
}
// Second call is a no-op.
if err := st.EnsureSendLogRetentionDays(30); err != nil {
t.Fatalf("EnsureSendLogRetentionDays again: %v", err)
}
got, err = st.GetSendLogRetentionDays(90)
if err != nil {
t.Fatalf("GetSendLogRetentionDays: %v", err)
}
if got != 120 {
t.Fatalf("after second ensure got %d, want 120", got)
}
}
func TestSendLogRetentionDaysMissingUsesEnvDefault(t *testing.T) {
st := openTestStore(t)
got, err := st.GetSendLogRetentionDays(60)
if err != nil {
t.Fatalf("GetSendLogRetentionDays: %v", err)
}
if got != 60 {
t.Fatalf("got %d, want 60", got)
}
}
func TestSendLogRetentionDaysInvalidStoredFallsBack(t *testing.T) {
st := openTestStore(t)
if err := st.SetSetting(SendLogRetentionDaysKey, "not-a-number"); err != nil {
t.Fatalf("SetSetting: %v", err)
}
got, err := st.GetSendLogRetentionDays(60)
if err != nil {
t.Fatalf("GetSendLogRetentionDays: %v", err)
}
if got != 60 {
t.Fatalf("got %d, want env fallback 60", got)
}
}